From 886af83156817b503dd9f877e95c10b4eca80762 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Sat, 6 Jun 2026 07:46:13 -0500 Subject: [PATCH 01/20] FEA-1550: Add SQLite to PGlite migration - Add a recoverable SQLite to PGlite migration layer for agent dashboard data - Wire dashboard startup through database preparation before backend handoff - Copy supported tables with row-count verification and .bak retention handling - Cover successful migration, fallback, skip, startup, and backup cleanup paths - Bump desktop minor version to 0.16.0 for the database technology transition Testing: Full desktop test suite, typecheck, lint, and focused migration tests passed Risks: Migration currently covers the in-process Agent Dashboard tables present in this repo --- apps/desktop/package.json | 3 +- .../main/agent-dashboard-database-startup.ts | 84 +++ .../agent-dashboard-design-system-runtime.ts | 5 +- apps/desktop/src/main/app.ts | 10 +- .../database/sqlite-to-pglite-migration.ts | 495 ++++++++++++++++++ .../test/sqlite-to-pglite-migration.test.ts | 331 ++++++++++++ pnpm-lock.yaml | 8 + 7 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/agent-dashboard-database-startup.ts create mode 100644 apps/desktop/src/main/database/sqlite-to-pglite-migration.ts create mode 100644 apps/desktop/test/sqlite-to-pglite-migration.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 969b3809..a5f568c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.117", + "version": "0.16.0", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, @@ -45,6 +45,7 @@ "dependencies": { "@closedloop-ai/design-system": "0.1.1-dev.26892521643.1", "@closedloop-ai/loops-api": ">=0.3.1", + "@electric-sql/pglite": "^0.4.6", "@pydantic/genai-prices": "0.0.62", "agent-dashboard": "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40", "busboy": "^1.6.0", diff --git a/apps/desktop/src/main/agent-dashboard-database-startup.ts b/apps/desktop/src/main/agent-dashboard-database-startup.ts new file mode 100644 index 00000000..0654cc88 --- /dev/null +++ b/apps/desktop/src/main/agent-dashboard-database-startup.ts @@ -0,0 +1,84 @@ +import path from "node:path"; +import { + cleanupExpiredSqliteBackups, + migrateSqliteToPglite, + resolvePgliteDataDir, + type SqliteToPgliteMigrationResult, +} from "./database/sqlite-to-pglite-migration.js"; + +export type AgentDashboardDatabaseBackend = "sqlite" | "pglite"; + +export type AgentDashboardDatabaseStartupResult = + | { + backend: "sqlite"; + sqlitePath: string; + pgliteDataDir: string; + migration?: SqliteToPgliteMigrationResult; + } + | { + backend: "pglite"; + sqlitePath: string; + pgliteDataDir: string; + migration: SqliteToPgliteMigrationResult; + }; + +export function resolveAgentDashboardDatabasePathForUserData( + userDataPath: string, +): string { + return path.join(userDataPath, "agent-dashboard.sqlite"); +} + +/** + * Startup-owned preparation for the dashboard database engine. SQLite mode only + * performs stale backup cleanup so the current SQLite runtime cannot rename its + * own live database. PGlite mode runs the forward migration before the PGlite + * runtime opens; failure returns a SQLite fallback result and leaves the source + * DB intact for retry on the next launch. + */ +export async function prepareAgentDashboardDatabaseStartup(options: { + userDataPath: string; + backend: AgentDashboardDatabaseBackend; + log?: (scope: string, message: string) => void; +}): Promise { + const sqlitePath = resolveAgentDashboardDatabasePathForUserData( + options.userDataPath, + ); + const pgliteDataDir = resolvePgliteDataDir(sqlitePath); + const log = options.log ?? (() => {}); + + if (options.backend === "sqlite") { + const removed = await cleanupExpiredSqliteBackups(sqlitePath); + if (removed > 0) { + log( + "agent-dashboard-migration", + `Removed ${removed} expired SQLite backup(s) for ${sqlitePath}`, + ); + } + return { backend: "sqlite", sqlitePath, pgliteDataDir }; + } + + const migration = await migrateSqliteToPglite({ + sqlitePath, + pgliteDataDir, + log: (message) => log("agent-dashboard-migration", message), + }); + if (migration.status === "failed") { + log( + "agent-dashboard-migration", + `Falling back to SQLite after PGlite migration failure: ${migration.error}`, + ); + return { + backend: "sqlite", + sqlitePath, + pgliteDataDir, + migration, + }; + } + + return { + backend: "pglite", + sqlitePath, + pgliteDataDir, + migration, + }; +} diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 05804028..03bc2bd3 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -11,8 +11,11 @@ import { detectBillingMode } from "./billing-mode-detector.js"; import { openAgentDatabase, type AgentDatabase } from "./database/index.js"; import { coerceDbId } from "./database/ipc-validation.js"; import { createLifecycle } from "./database/lifecycle.js"; +import { resolveAgentDashboardDatabasePathForUserData } from "./agent-dashboard-database-startup.js"; import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js"; +export { prepareAgentDashboardDatabaseStartup } from "./agent-dashboard-database-startup.js"; + const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ "desktop:db:get-sessions", "desktop:db:get-sessions-page", @@ -59,7 +62,7 @@ export interface AgentDashboardDesignSystemRuntime { export function resolveAgentDashboardDatabasePath( userDataPath = app.getPath("userData"), ): string { - return path.join(userDataPath, "agent-dashboard.sqlite"); + return resolveAgentDashboardDatabasePathForUserData(userDataPath); } /** diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 893c2492..f5a26ac9 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1509,9 +1509,17 @@ export class DesktopApplication { return null; } if (!this.agentDashboardDesignSystem) { - const { createAgentDashboardDesignSystemRuntime } = await import( + const { + createAgentDashboardDesignSystemRuntime, + prepareAgentDashboardDatabaseStartup, + } = await import( "./agent-dashboard-design-system-runtime.js" ); + await prepareAgentDashboardDatabaseStartup({ + userDataPath: app.getPath("userData"), + backend: "sqlite", + log: (scope, message) => gatewayLog.info(scope, message), + }); this.agentDashboardDesignSystem = createAgentDashboardDesignSystemRuntime({ userDataPath: app.getPath("userData"), diff --git a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts new file mode 100644 index 00000000..f7456b20 --- /dev/null +++ b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts @@ -0,0 +1,495 @@ +import { DatabaseSync } from "node:sqlite"; +import { constants as fsConstants } from "node:fs"; +import { + access, + mkdir, + readdir, + rename, + rm, + stat, +} from "node:fs/promises"; +import path from "node:path"; +import { PGlite, type Results } from "@electric-sql/pglite"; + +const BACKUP_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const PGLITE_DIRECTORY_SUFFIX = ".pgdata"; + +const TABLES = [ + { + name: "sessions", + conflictTarget: "id", + columns: [ + "id", + "name", + "status", + "cwd", + "model", + "started_at", + "updated_at", + "ended_at", + "awaiting_input_since", + "metadata", + "harness", + "billing_mode", + "user_id", + "organization_id", + ], + }, + { + name: "agents", + conflictTarget: "id", + columns: [ + "id", + "session_id", + "name", + "type", + "subagent_type", + "status", + "task", + "current_tool", + "started_at", + "updated_at", + "ended_at", + "awaiting_input_since", + "parent_agent_id", + "metadata", + "user_id", + "organization_id", + ], + }, + { + name: "events", + conflictTarget: "id", + columns: [ + "id", + "session_id", + "agent_id", + "event_type", + "tool_name", + "summary", + "data", + "created_at", + "user_id", + "organization_id", + ], + }, + { + name: "token_usage", + conflictTarget: "session_id, model", + columns: [ + "session_id", + "model", + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + "raw_input", + "raw_output", + "raw_cache_read", + "raw_cache_write", + "created_at", + "updated_at", + "user_id", + "organization_id", + ], + }, +] as const; + +type TableName = (typeof TABLES)[number]["name"]; +type TableCounts = Record; + +export type SqliteToPgliteMigrationResult = + | { + status: "skipped"; + reason: "sqlite_missing" | "already_migrated"; + sqlitePath: string; + pgliteDataDir: string; + } + | { + status: "migrated"; + sqlitePath: string; + sqliteBackupPath: string; + pgliteDataDir: string; + rowCounts: TableCounts; + } + | { + status: "failed"; + sqlitePath: string; + pgliteDataDir: string; + error: string; + failedAt: string; + }; + +export interface SqliteToPgliteMigrationOptions { + sqlitePath: string; + pgliteDataDir?: string; + now?: () => Date; + log?: (message: string) => void; +} + +interface PgliteExecutor { + exec(query: string): Promise; + query = Record>( + query: string, + params?: unknown[], + ): Promise>; +} + +interface PgliteClient extends PgliteExecutor { + transaction(callback: (tx: PgliteExecutor) => Promise): Promise; + close(): Promise; +} + +export function resolvePgliteDataDir(sqlitePath: string): string { + const parsed = path.parse(sqlitePath); + return path.join(parsed.dir, `${parsed.name}${PGLITE_DIRECTORY_SUFFIX}`); +} + +export async function cleanupExpiredSqliteBackups( + sqlitePath: string, + now = new Date(), + retentionMs = BACKUP_RETENTION_MS, +): Promise { + const dir = path.dirname(sqlitePath); + const backupPrefix = `${path.basename(sqlitePath)}.bak`; + let removed = 0; + + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return 0; + } + + await Promise.all( + entries + .filter((entry) => entry === backupPrefix || entry.startsWith(`${backupPrefix}.`)) + .map(async (entry) => { + const backupPath = path.join(dir, entry); + const info = await stat(backupPath).catch(() => null); + if (!info) { + return; + } + if (now.getTime() - info.mtime.getTime() < retentionMs) { + return; + } + await rm(backupPath, { force: true, recursive: true }); + removed += 1; + }), + ); + + return removed; +} + +export async function migrateSqliteToPglite( + options: SqliteToPgliteMigrationOptions, +): Promise { + const sqlitePath = options.sqlitePath; + const pgliteDataDir = options.pgliteDataDir ?? resolvePgliteDataDir(sqlitePath); + const log = options.log ?? (() => {}); + + await cleanupExpiredSqliteBackups(sqlitePath, options.now?.() ?? new Date()); + + if (!(await fileExists(sqlitePath))) { + return { + status: "skipped", + reason: (await fileExists(`${sqlitePath}.bak`)) + ? "already_migrated" + : "sqlite_missing", + sqlitePath, + pgliteDataDir, + }; + } + + const backupPath = `${sqlitePath}.bak`; + + let sqlite: DatabaseSync | null = null; + let pglite: PgliteClient | null = null; + try { + await mkdir(pgliteDataDir, { recursive: true }); + sqlite = new DatabaseSync(sqlitePath); + pglite = await PGlite.create(pgliteDataDir); + + const sourceSchema = readSqliteSchema(sqlite); + const sourceCounts = readSourceCounts(sqlite, sourceSchema); + await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts); + + sqlite.close(); + sqlite = null; + await pglite.close(); + pglite = null; + + await rotateExistingBackup(backupPath); + await rename(sqlitePath, backupPath); + log( + `SQLite to PGlite migration succeeded: sqlite=${sqlitePath}, pglite=${pgliteDataDir}`, + ); + + return { + status: "migrated", + sqlitePath, + sqliteBackupPath: backupPath, + pgliteDataDir, + rowCounts: sourceCounts, + }; + } catch (error) { + log( + `SQLite to PGlite migration failed: sqlite=${sqlitePath}, pglite=${pgliteDataDir}, error=${formatError(error)}`, + ); + return { + status: "failed", + sqlitePath, + pgliteDataDir, + error: formatError(error), + failedAt: new Date().toISOString(), + }; + } finally { + try { + sqlite?.close(); + } catch { + /* ignore close failure */ + } + try { + await pglite?.close(); + } catch { + /* ignore close failure */ + } + } +} + +async function rotateExistingBackup(backupPath: string): Promise { + if (!(await fileExists(backupPath))) { + return; + } + const rotatedPath = `${backupPath}.${Date.now()}`; + await rename(backupPath, rotatedPath); +} + +async function initializeAndCopy( + sqlite: DatabaseSync, + pglite: PgliteClient, + sourceSchema: Map>, + sourceCounts: TableCounts, +): Promise { + await pglite.transaction(async (tx) => { + await tx.exec(PGLITE_SCHEMA); + await tx.exec(` + TRUNCATE TABLE + events, + agents, + token_usage, + sessions, + agent_database_metadata + RESTART IDENTITY CASCADE; + `); + + for (const table of TABLES) { + const sourceColumns = sourceSchema.get(table.name); + if (!sourceColumns) { + continue; + } + const columns = table.columns.filter((column) => sourceColumns.has(column)); + if (columns.length === 0) { + continue; + } + const rows = sqlite + .prepare(`SELECT ${columns.join(", ")} FROM ${table.name}`) + .all() as Record[]; + + for (const row of rows) { + await insertRow(tx, table.name, table.conflictTarget, columns, row); + } + } + + const destinationCounts = await readDestinationCounts(tx); + for (const table of TABLES) { + if (sourceCounts[table.name] !== destinationCounts[table.name]) { + throw new Error( + `row count mismatch for ${table.name}: sqlite=${sourceCounts[table.name]} pglite=${destinationCounts[table.name]}`, + ); + } + } + + await tx.query( + ` + INSERT INTO agent_database_metadata (key, value) + VALUES ($1, $2) + `, + [ + "sqlite_to_pglite_migrated_at", + JSON.stringify({ + migratedAt: new Date().toISOString(), + rowCounts: destinationCounts, + }), + ], + ); + }); +} + +async function insertRow( + pglite: PgliteExecutor, + tableName: string, + conflictTarget: string, + columns: readonly string[], + row: Record, +): Promise { + const placeholders = columns.map((_, index) => `$${index + 1}`).join(", "); + const values = columns.map((column) => row[column] ?? null); + await pglite.query( + ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictTarget}) DO NOTHING + `, + values, + ); +} + +function readSqliteSchema(sqlite: DatabaseSync): Map> { + const schema = new Map>(); + for (const table of TABLES) { + const rows = sqlite.prepare(`PRAGMA table_info(${table.name})`).all() as Array<{ + name: string; + }>; + if (rows.length === 0) { + continue; + } + schema.set(table.name, new Set(rows.map((row) => row.name))); + } + return schema; +} + +function readSourceCounts( + sqlite: DatabaseSync, + sourceSchema: Map>, +): TableCounts { + return Object.fromEntries( + TABLES.map((table) => [ + table.name, + sourceSchema.has(table.name) + ? ((sqlite.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { + count: number; + }).count ?? 0) + : 0, + ]), + ) as TableCounts; +} + +async function readDestinationCounts(pglite: PgliteExecutor): Promise { + const entries: Array<[TableName, number]> = []; + for (const table of TABLES) { + const result = await pglite.query<{ count: string }>( + `SELECT COUNT(*)::text as count FROM ${table.name}`, + ); + entries.push([table.name, Number(result.rows[0]?.count ?? 0)]); + } + return Object.fromEntries(entries) as TableCounts; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? error.message; + } + return String(error); +} + +const PGLITE_SCHEMA = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT, + status TEXT NOT NULL DEFAULT 'running', + cwd TEXT, + model TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + metadata TEXT, + harness TEXT, + billing_mode TEXT, + user_id TEXT, + organization_id TEXT +); + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + name TEXT, + type TEXT, + subagent_type TEXT, + status TEXT NOT NULL DEFAULT 'running', + task TEXT, + current_tool TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + parent_agent_id TEXT, + metadata TEXT, + user_id TEXT, + organization_id TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id); +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); +CREATE INDEX IF NOT EXISTS idx_agents_type ON agents(type); +CREATE INDEX IF NOT EXISTS idx_agents_parent ON agents(parent_agent_id) WHERE parent_agent_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + agent_id TEXT, + event_type TEXT NOT NULL, + tool_name TEXT, + summary TEXT, + data TEXT, + created_at TEXT, + user_id TEXT, + organization_id TEXT +); + +CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id); +CREATE INDEX IF NOT EXISTS idx_events_agent_id ON events(agent_id); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); +CREATE INDEX IF NOT EXISTS idx_events_tool_name ON events(tool_name) WHERE tool_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_events_session_tool ON events(session_id, created_at) WHERE tool_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); +CREATE INDEX IF NOT EXISTS idx_events_tool_created ON events(created_at, tool_name) WHERE tool_name IS NOT NULL; + +CREATE TABLE IF NOT EXISTS token_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + raw_input INTEGER NOT NULL DEFAULT 0, + raw_output INTEGER NOT NULL DEFAULT 0, + raw_cache_read INTEGER NOT NULL DEFAULT 0, + raw_cache_write INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (now()::text), + updated_at TEXT, + user_id TEXT, + organization_id TEXT, + PRIMARY KEY (session_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_token_usage_session ON token_usage(session_id); + +CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_status_started_at ON sessions(status, started_at DESC); + +CREATE TABLE IF NOT EXISTS agent_database_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +`; diff --git a/apps/desktop/test/sqlite-to-pglite-migration.test.ts b/apps/desktop/test/sqlite-to-pglite-migration.test.ts new file mode 100644 index 00000000..b4f43c8f --- /dev/null +++ b/apps/desktop/test/sqlite-to-pglite-migration.test.ts @@ -0,0 +1,331 @@ +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, + utimesSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { PGlite } from "@electric-sql/pglite"; +import { + cleanupExpiredSqliteBackups, + migrateSqliteToPglite, + resolvePgliteDataDir, +} from "../src/main/database/sqlite-to-pglite-migration.js"; +import { prepareAgentDashboardDatabaseStartup } from "../src/main/agent-dashboard-database-startup.js"; + +function makeTempDir(): string { + return mkdtempSync(path.join(tmpdir(), "cl-pglite-migration-")); +} + +function seedSqlite(dbPath: string): void { + const db = new DatabaseSync(dbPath); + try { + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + name TEXT, + status TEXT NOT NULL DEFAULT 'running', + cwd TEXT, + model TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + metadata TEXT, + harness TEXT, + billing_mode TEXT, + user_id TEXT, + organization_id TEXT + ); + + CREATE TABLE agents ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + name TEXT, + type TEXT, + subagent_type TEXT, + status TEXT NOT NULL DEFAULT 'running', + task TEXT, + current_tool TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + parent_agent_id TEXT, + metadata TEXT, + user_id TEXT, + organization_id TEXT + ); + + CREATE TABLE events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + agent_id TEXT, + event_type TEXT NOT NULL, + tool_name TEXT, + summary TEXT, + data TEXT, + created_at TEXT, + user_id TEXT, + organization_id TEXT + ); + + CREATE TABLE token_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + raw_input INTEGER NOT NULL DEFAULT 0, + raw_output INTEGER NOT NULL DEFAULT 0, + raw_cache_read INTEGER NOT NULL DEFAULT 0, + raw_cache_write INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + updated_at TEXT, + user_id TEXT, + organization_id TEXT, + PRIMARY KEY (session_id, model) + ); + `); + db.prepare(` + INSERT INTO sessions ( + id, name, status, cwd, model, started_at, updated_at, harness, + billing_mode, user_id, organization_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "session-1", + "Migration fixture", + "completed", + "/repo", + "claude-sonnet-4-6", + "2026-06-01T00:00:00.000Z", + "2026-06-01T00:01:00.000Z", + "claude", + "api", + "user-1", + "org-1", + ); + db.prepare(` + INSERT INTO agents ( + id, session_id, name, type, status, started_at, updated_at, user_id, organization_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "session-1-main", + "session-1", + "main", + "main", + "completed", + "2026-06-01T00:00:00.000Z", + "2026-06-01T00:01:00.000Z", + "user-1", + "org-1", + ); + db.prepare(` + INSERT INTO events ( + id, session_id, agent_id, event_type, tool_name, summary, data, + created_at, user_id, organization_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "event-1", + "session-1", + "session-1-main", + "PreToolUse", + "Bash", + "Ran command", + "{\"ok\":true}", + "2026-06-01T00:00:30.000Z", + "user-1", + "org-1", + ); + db.prepare(` + INSERT INTO token_usage ( + session_id, model, input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, raw_input, raw_output, raw_cache_read, + raw_cache_write, created_at, updated_at, user_id, organization_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "session-1", + "claude-sonnet-4-6", + 100, + 20, + 5, + 1, + 100, + 20, + 5, + 1, + "2026-06-01T00:00:00.000Z", + "2026-06-01T00:01:00.000Z", + "user-1", + "org-1", + ); + } finally { + db.close(); + } +} + +test("migrateSqliteToPglite copies rows, preserves attribution columns, and renames SQLite to .bak", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + + const result = await migrateSqliteToPglite({ sqlitePath }); + + assert.equal(result.status, "migrated"); + assert.equal(existsSync(sqlitePath), false, "SQLite source should be renamed"); + assert.equal(existsSync(`${sqlitePath}.bak`), true, "SQLite backup should remain"); + assert.deepEqual(result.status === "migrated" ? result.rowCounts : {}, { + sessions: 1, + agents: 1, + events: 1, + token_usage: 1, + }); + + const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); + try { + const sessions = await pg.query<{ + id: string; + user_id: string | null; + organization_id: string | null; + }>("SELECT id, user_id, organization_id FROM sessions"); + assert.deepEqual(sessions.rows, [ + { id: "session-1", user_id: "user-1", organization_id: "org-1" }, + ]); + + const tokenUsage = await pg.query<{ input_tokens: number; output_tokens: number }>( + "SELECT input_tokens, output_tokens FROM token_usage", + ); + assert.deepEqual(tokenUsage.rows, [{ input_tokens: 100, output_tokens: 20 }]); + } finally { + await pg.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("migrateSqliteToPglite returns failed and leaves SQLite intact when PGlite initialization fails", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + const pgliteDataDir = path.join(dir, "not-a-directory"); + seedSqlite(sqlitePath); + writeFileSync(pgliteDataDir, "blocks PGlite directory creation"); + + const result = await migrateSqliteToPglite({ sqlitePath, pgliteDataDir }); + + assert.equal(result.status, "failed"); + assert.equal(existsSync(sqlitePath), true, "SQLite source must remain usable"); + assert.equal(existsSync(`${sqlitePath}.bak`), false, "failed migration must not create backup"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("migrateSqliteToPglite skips when only the retained SQLite backup remains", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + writeFileSync(`${sqlitePath}.bak`, "backup"); + + const result = await migrateSqliteToPglite({ sqlitePath }); + + assert.equal(result.status, "skipped"); + assert.equal(result.status === "skipped" ? result.reason : "", "already_migrated"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareAgentDashboardDatabaseStartup leaves SQLite live for the SQLite backend", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + + const result = await prepareAgentDashboardDatabaseStartup({ + userDataPath: dir, + backend: "sqlite", + }); + + assert.equal(result.backend, "sqlite"); + assert.equal(existsSync(sqlitePath), true, "SQLite runtime must keep its live DB"); + assert.equal(existsSync(`${sqlitePath}.bak`), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareAgentDashboardDatabaseStartup migrates before selecting the PGlite backend", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + + const result = await prepareAgentDashboardDatabaseStartup({ + userDataPath: dir, + backend: "pglite", + }); + + assert.equal(result.backend, "pglite"); + assert.equal(result.migration.status, "migrated"); + assert.equal(existsSync(sqlitePath), false); + assert.equal(existsSync(`${sqlitePath}.bak`), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareAgentDashboardDatabaseStartup falls back to SQLite when PGlite migration fails", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + const pgliteDataDir = resolvePgliteDataDir(sqlitePath); + seedSqlite(sqlitePath); + writeFileSync(pgliteDataDir, "blocks PGlite directory creation"); + + const result = await prepareAgentDashboardDatabaseStartup({ + userDataPath: dir, + backend: "pglite", + }); + + assert.equal(result.backend, "sqlite"); + assert.equal(result.migration?.status, "failed"); + assert.equal(existsSync(sqlitePath), true, "fallback keeps SQLite live"); + assert.equal(existsSync(`${sqlitePath}.bak`), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cleanupExpiredSqliteBackups deletes backups once the 30 day safety window has elapsed", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + const backupPath = `${sqlitePath}.bak`; + writeFileSync(backupPath, "backup"); + const old = new Date("2026-01-01T00:00:00.000Z"); + utimesSync(backupPath, old, old); + + const removed = await cleanupExpiredSqliteBackups( + sqlitePath, + new Date("2026-02-01T00:00:00.000Z"), + ); + + assert.equal(removed, 1); + assert.equal(existsSync(backupPath), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77628c0c..347f13b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@closedloop-ai/loops-api': specifier: '>=0.3.1' version: 0.3.1(zod@4.3.6) + '@electric-sql/pglite': + specifier: ^0.4.6 + version: 0.4.6 '@pydantic/genai-prices': specifier: 0.0.62 version: 0.0.62 @@ -267,6 +270,9 @@ packages: '@egjs/list-differ@1.0.1': resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==} + '@electric-sql/pglite@0.4.6': + resolution: {integrity: sha512-qmlmfN8UyKCee35qkV0r/MBp+Znl8FjBz7OpoglNvww3GJpw0/DLP0o1ZymvLNmcD5DTLOQdzKPtF8Hd3mdl1w==} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -5289,6 +5295,8 @@ snapshots: '@egjs/list-differ@1.0.1': {} + '@electric-sql/pglite@0.4.6': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 From 293b96029662852189c32651b490ee7fdd0d08e4 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Sun, 7 Jun 2026 00:19:00 -0500 Subject: [PATCH 02/20] FEA-1550: Review feedback hardening for SQLite->PGlite migration - Restrict backup cleanup to exact naming pattern, regular files only, no recursive delete - Stamp backup mtime with migration time instead of preserving SQLite mtime - Stage PGlite in temp directory, promote only after backup succeeds - Sanitize absolute paths and error stacks from logs - Batch inserts (500 rows) instead of per-row INSERT - Validate all SQLite tables are managed before migration - Require .bak + .pgdata for already_migrated; detect downgrade scenario - Wire migration into startup with keepSource, non-blocking background execution - Pass startup result to runtime for PGlite readiness signaling Testing: Full migration test suite (9 tests), typecheck, and lint pass --- apps/desktop/package.json | 2 +- .../main/agent-dashboard-database-startup.ts | 61 +++--- .../agent-dashboard-design-system-runtime.ts | 28 ++- apps/desktop/src/main/app.ts | 5 +- .../database/sqlite-to-pglite-migration.ts | 177 +++++++++++++----- .../test/sqlite-to-pglite-migration.test.ts | 110 +++++++++-- 6 files changed, 286 insertions(+), 97 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a5f568c3..b3f0142f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.16.0", + "version": "0.16.1", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/agent-dashboard-database-startup.ts b/apps/desktop/src/main/agent-dashboard-database-startup.ts index 0654cc88..5028a1df 100644 --- a/apps/desktop/src/main/agent-dashboard-database-startup.ts +++ b/apps/desktop/src/main/agent-dashboard-database-startup.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import { basename, join } from "node:path"; import { cleanupExpiredSqliteBackups, migrateSqliteToPglite, @@ -19,22 +19,16 @@ export type AgentDashboardDatabaseStartupResult = backend: "pglite"; sqlitePath: string; pgliteDataDir: string; - migration: SqliteToPgliteMigrationResult; + migration?: SqliteToPgliteMigrationResult; + migrationPromise: Promise; }; export function resolveAgentDashboardDatabasePathForUserData( userDataPath: string, ): string { - return path.join(userDataPath, "agent-dashboard.sqlite"); + return join(userDataPath, "agent-dashboard.sqlite"); } -/** - * Startup-owned preparation for the dashboard database engine. SQLite mode only - * performs stale backup cleanup so the current SQLite runtime cannot rename its - * own live database. PGlite mode runs the forward migration before the PGlite - * runtime opens; failure returns a SQLite fallback result and leaves the source - * DB intact for retry on the next launch. - */ export async function prepareAgentDashboardDatabaseStartup(options: { userDataPath: string; backend: AgentDashboardDatabaseBackend; @@ -46,39 +40,54 @@ export async function prepareAgentDashboardDatabaseStartup(options: { const pgliteDataDir = resolvePgliteDataDir(sqlitePath); const log = options.log ?? (() => {}); + const removed = await cleanupExpiredSqliteBackups(sqlitePath); + if (removed > 0) { + log( + "agent-dashboard-migration", + `Removed ${removed} expired SQLite backup(s) for ${basename(sqlitePath)}`, + ); + } + if (options.backend === "sqlite") { - const removed = await cleanupExpiredSqliteBackups(sqlitePath); - if (removed > 0) { + return { backend: "sqlite", sqlitePath, pgliteDataDir }; + } + + const onSettled = (migration: SqliteToPgliteMigrationResult) => { + if (migration.status === "failed") { log( "agent-dashboard-migration", - `Removed ${removed} expired SQLite backup(s) for ${sqlitePath}`, + `PGlite migration failed (runtime continues on SQLite): ${migration.error}`, + ); + } else if (migration.status === "migrated") { + log( + "agent-dashboard-migration", + `PGlite migration completed (${migration.rowCounts.sessions} sessions); SQLite preserved for sync runtime`, ); } - return { backend: "sqlite", sqlitePath, pgliteDataDir }; - } + return migration; + }; - const migration = await migrateSqliteToPglite({ + const migrationPromise = migrateSqliteToPglite({ sqlitePath, pgliteDataDir, + keepSource: true, log: (message) => log("agent-dashboard-migration", message), - }); - if (migration.status === "failed") { - log( - "agent-dashboard-migration", - `Falling back to SQLite after PGlite migration failure: ${migration.error}`, - ); + }).then(onSettled, (error: unknown) => { + const err = error instanceof Error ? error.message : String(error); + log("agent-dashboard-migration", `PGlite migration failed: ${err}`); return { - backend: "sqlite", + status: "failed" as const, sqlitePath, pgliteDataDir, - migration, + error: err, + failedAt: new Date().toISOString(), }; - } + }); return { backend: "pglite", sqlitePath, pgliteDataDir, - migration, + migrationPromise, }; } diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 03bc2bd3..fc378537 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -11,7 +11,10 @@ import { detectBillingMode } from "./billing-mode-detector.js"; import { openAgentDatabase, type AgentDatabase } from "./database/index.js"; import { coerceDbId } from "./database/ipc-validation.js"; import { createLifecycle } from "./database/lifecycle.js"; -import { resolveAgentDashboardDatabasePathForUserData } from "./agent-dashboard-database-startup.js"; +import { + resolveAgentDashboardDatabasePathForUserData, + type AgentDashboardDatabaseStartupResult, +} from "./agent-dashboard-database-startup.js"; import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js"; export { prepareAgentDashboardDatabaseStartup } from "./agent-dashboard-database-startup.js"; @@ -40,6 +43,7 @@ export interface AgentDashboardDesignSystemRuntimeOptions { onTerminalFailure: (reason: string) => void; userDataPath?: string; log?: (scope: string, message: string) => void; + startupResult?: AgentDashboardDatabaseStartupResult; } export interface AgentDashboardDesignSystemRuntime { @@ -74,6 +78,28 @@ export function createAgentDashboardDesignSystemRuntime( options: AgentDashboardDesignSystemRuntimeOptions, ): AgentDashboardDesignSystemRuntime { const log = options.log ?? (() => {}); + const startupResult = options.startupResult; + + if (startupResult?.backend === "pglite") { + log( + "agent-dashboard-migration", + "PGlite migration kicked off in background; SQLite runtime active during migration", + ); + void startupResult.migrationPromise.then((migration) => { + if (migration.status === "failed") { + log( + "agent-dashboard-migration", + `PGlite migration failed: ${migration.error}`, + ); + } else { + log( + "agent-dashboard-migration", + `PGlite migration completed (${migration.status === "migrated" ? `${migration.rowCounts.sessions} sessions` : "skipped"})`, + ); + } + }); + } + const agentDatabase = openAgentDatabase( resolveAgentDashboardDatabasePath(options.userDataPath), ); diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index f5a26ac9..004bec38 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1515,9 +1515,9 @@ export class DesktopApplication { } = await import( "./agent-dashboard-design-system-runtime.js" ); - await prepareAgentDashboardDatabaseStartup({ + const startupResult = await prepareAgentDashboardDatabaseStartup({ userDataPath: app.getPath("userData"), - backend: "sqlite", + backend: "pglite", log: (scope, message) => gatewayLog.info(scope, message), }); this.agentDashboardDesignSystem = @@ -1535,6 +1535,7 @@ export class DesktopApplication { this.refreshTrayState(); }, log: (scope, message) => gatewayLog.info(scope, message), + startupResult, }); this.agentDashboardDesignSystem.registerIpcHandlers(); } diff --git a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts index f7456b20..4c47cff4 100644 --- a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts +++ b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts @@ -2,17 +2,19 @@ import { DatabaseSync } from "node:sqlite"; import { constants as fsConstants } from "node:fs"; import { access, - mkdir, + mkdtemp, readdir, rename, rm, stat, + utimes, } from "node:fs/promises"; import path from "node:path"; import { PGlite, type Results } from "@electric-sql/pglite"; const BACKUP_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const PGLITE_DIRECTORY_SUFFIX = ".pgdata"; +const BATCH_SIZE = 500; const TABLES = [ { @@ -108,7 +110,7 @@ export type SqliteToPgliteMigrationResult = | { status: "migrated"; sqlitePath: string; - sqliteBackupPath: string; + sqliteBackupPath: string | null; pgliteDataDir: string; rowCounts: TableCounts; } @@ -125,6 +127,13 @@ export interface SqliteToPgliteMigrationOptions { pgliteDataDir?: string; now?: () => Date; log?: (message: string) => void; + /** + * When true, skip the final rename of the SQLite source to .bak. + * Used during startup migration so the existing SQLite runtime can + * continue serving reads and writes until the stores are migrated + * to PGlite. + */ + keepSource?: boolean; } interface PgliteExecutor { @@ -145,13 +154,15 @@ export function resolvePgliteDataDir(sqlitePath: string): string { return path.join(parsed.dir, `${parsed.name}${PGLITE_DIRECTORY_SUFFIX}`); } +const BACKUP_NAME_REGEX = /^(.+\.bak)(\.\d+)?$/; + export async function cleanupExpiredSqliteBackups( sqlitePath: string, now = new Date(), retentionMs = BACKUP_RETENTION_MS, ): Promise { const dir = path.dirname(sqlitePath); - const backupPrefix = `${path.basename(sqlitePath)}.bak`; + const basename = path.basename(sqlitePath); let removed = 0; let entries: string[]; @@ -163,17 +174,21 @@ export async function cleanupExpiredSqliteBackups( await Promise.all( entries - .filter((entry) => entry === backupPrefix || entry.startsWith(`${backupPrefix}.`)) + .filter( + (entry) => + entry === `${basename}.bak` || + BACKUP_NAME_REGEX.test(entry) && entry.startsWith(`${basename}.bak`), + ) .map(async (entry) => { const backupPath = path.join(dir, entry); const info = await stat(backupPath).catch(() => null); - if (!info) { + if (!info?.isFile()) { return; } if (now.getTime() - info.mtime.getTime() < retentionMs) { return; } - await rm(backupPath, { force: true, recursive: true }); + await rm(backupPath, { force: true }); removed += 1; }), ); @@ -187,13 +202,17 @@ export async function migrateSqliteToPglite( const sqlitePath = options.sqlitePath; const pgliteDataDir = options.pgliteDataDir ?? resolvePgliteDataDir(sqlitePath); const log = options.log ?? (() => {}); + const stampTime = options.now?.() ?? new Date(); + + await cleanupExpiredSqliteBackups(sqlitePath, stampTime); - await cleanupExpiredSqliteBackups(sqlitePath, options.now?.() ?? new Date()); + const backupExists = await fileExists(`${sqlitePath}.bak`); + const pgdataExists = await fileExists(pgliteDataDir); if (!(await fileExists(sqlitePath))) { return { status: "skipped", - reason: (await fileExists(`${sqlitePath}.bak`)) + reason: backupExists && pgdataExists ? "already_migrated" : "sqlite_missing", sqlitePath, @@ -201,46 +220,79 @@ export async function migrateSqliteToPglite( }; } + if (backupExists && pgdataExists) { + await rm(sqlitePath, { force: true }); + return { + status: "skipped", + reason: "already_migrated", + sqlitePath, + pgliteDataDir, + }; + } + const backupPath = `${sqlitePath}.bak`; let sqlite: DatabaseSync | null = null; let pglite: PgliteClient | null = null; try { - await mkdir(pgliteDataDir, { recursive: true }); - sqlite = new DatabaseSync(sqlitePath); - pglite = await PGlite.create(pgliteDataDir); - - const sourceSchema = readSqliteSchema(sqlite); - const sourceCounts = readSourceCounts(sqlite, sourceSchema); - await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts); - - sqlite.close(); - sqlite = null; - await pglite.close(); - pglite = null; - - await rotateExistingBackup(backupPath); - await rename(sqlitePath, backupPath); - log( - `SQLite to PGlite migration succeeded: sqlite=${sqlitePath}, pglite=${pgliteDataDir}`, + const stagingDir = await mkdtemp( + path.join(path.dirname(pgliteDataDir), ".pglite-staging-"), ); + try { + sqlite = new DatabaseSync(sqlitePath); + assertAllTablesManaged(sqlite); + pglite = await PGlite.create(stagingDir); + + const sourceSchema = readSqliteSchema(sqlite); + const sourceCounts = readSourceCounts(sqlite, sourceSchema); + await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts); + + sqlite.close(); + sqlite = null; + await pglite.close(); + pglite = null; + + let sqliteBackupPath: string | null = null; + if (options.keepSource) { + log( + `SQLite to PGlite migration succeeded: db=${sanitizePath(sqlitePath)}, source preserved for runtime`, + ); + } else { + await rotateExistingBackup(backupPath); + await rename(sqlitePath, backupPath); + await utimes(backupPath, stampTime, stampTime); + sqliteBackupPath = backupPath; + log( + `SQLite to PGlite migration succeeded: db=${sanitizePath(sqlitePath)}, backup stamped at ${stampTime.toISOString()}`, + ); + } - return { - status: "migrated", - sqlitePath, - sqliteBackupPath: backupPath, - pgliteDataDir, - rowCounts: sourceCounts, - }; + await rm(pgliteDataDir, { recursive: true, force: true }).catch( + () => {}, + ); + await rename(stagingDir, pgliteDataDir); + + return { + status: "migrated", + sqlitePath, + sqliteBackupPath, + pgliteDataDir, + rowCounts: sourceCounts, + }; + } finally { + await rm(stagingDir, { recursive: true, force: true }).catch( + () => {}, + ); + } } catch (error) { log( - `SQLite to PGlite migration failed: sqlite=${sqlitePath}, pglite=${pgliteDataDir}, error=${formatError(error)}`, + `SQLite to PGlite migration failed: sqlite=${sqlitePath}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`, ); return { status: "failed", sqlitePath, pgliteDataDir, - error: formatError(error), + error: sanitizeError(error), failedAt: new Date().toISOString(), }; } finally { @@ -296,8 +348,15 @@ async function initializeAndCopy( .prepare(`SELECT ${columns.join(", ")} FROM ${table.name}`) .all() as Record[]; - for (const row of rows) { - await insertRow(tx, table.name, table.conflictTarget, columns, row); + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + await batchInsertRows( + tx, + table.name, + table.conflictTarget, + columns, + batch, + ); } } @@ -326,22 +385,27 @@ async function initializeAndCopy( }); } -async function insertRow( +async function batchInsertRows( pglite: PgliteExecutor, tableName: string, conflictTarget: string, columns: readonly string[], - row: Record, + rows: Record[], ): Promise { - const placeholders = columns.map((_, index) => `$${index + 1}`).join(", "); - const values = columns.map((column) => row[column] ?? null); + const params: unknown[] = []; + const valueRows: string[] = []; + + for (const row of rows) { + const rowParams = columns.map((col) => { + params.push(row[col] ?? null); + return `$${params.length}`; + }); + valueRows.push(`(${rowParams.join(", ")})`); + } + await pglite.query( - ` - INSERT INTO ${tableName} (${columns.join(", ")}) - VALUES (${placeholders}) - ON CONFLICT (${conflictTarget}) DO NOTHING - `, - values, + `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES ${valueRows.join(", ")} ON CONFLICT (${conflictTarget}) DO NOTHING`, + params, ); } @@ -386,6 +450,19 @@ async function readDestinationCounts(pglite: PgliteExecutor): Promise(TABLES.map((t) => t.name)); + const rows = sqlite.prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, + ).all() as { name: string }[]; + const unmanaged = rows.filter((row) => !tableNames.has(row.name)); + if (unmanaged.length > 0) { + throw new Error( + `Unmanaged table(s) found in source SQLite: ${unmanaged.map((r) => r.name).join(", ")}. Add these to TABLES before migrating.`, + ); + } +} + async function fileExists(filePath: string): Promise { try { await access(filePath, fsConstants.F_OK); @@ -395,9 +472,13 @@ async function fileExists(filePath: string): Promise { } } -function formatError(error: unknown): string { +function sanitizePath(filePath: string): string { + return path.basename(filePath); +} + +function sanitizeError(error: unknown): string { if (error instanceof Error) { - return error.stack ?? error.message; + return `${error.name}: ${error.message}`; } return String(error); } diff --git a/apps/desktop/test/sqlite-to-pglite-migration.test.ts b/apps/desktop/test/sqlite-to-pglite-migration.test.ts index b4f43c8f..e781b8b0 100644 --- a/apps/desktop/test/sqlite-to-pglite-migration.test.ts +++ b/apps/desktop/test/sqlite-to-pglite-migration.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { DatabaseSync } from "node:sqlite"; import { existsSync, + mkdirSync, mkdtempSync, rmSync, writeFileSync, @@ -215,29 +216,39 @@ test("migrateSqliteToPglite copies rows, preserves attribution columns, and rena } }); -test("migrateSqliteToPglite returns failed and leaves SQLite intact when PGlite initialization fails", async () => { +test("migrateSqliteToPglite returns failed and leaves SQLite intact with unmanaged source tables", async () => { const dir = makeTempDir(); try { const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - const pgliteDataDir = path.join(dir, "not-a-directory"); - seedSqlite(sqlitePath); - writeFileSync(pgliteDataDir, "blocks PGlite directory creation"); + const db = new DatabaseSync(sqlitePath); + db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)"); + db.close(); - const result = await migrateSqliteToPglite({ sqlitePath, pgliteDataDir }); + const result = await migrateSqliteToPglite({ sqlitePath }); assert.equal(result.status, "failed"); - assert.equal(existsSync(sqlitePath), true, "SQLite source must remain usable"); - assert.equal(existsSync(`${sqlitePath}.bak`), false, "failed migration must not create backup"); + assert.equal( + existsSync(sqlitePath), + true, + "SQLite source must remain usable", + ); + assert.equal( + existsSync(`${sqlitePath}.bak`), + false, + "failed migration must not create backup", + ); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("migrateSqliteToPglite skips when only the retained SQLite backup remains", async () => { +test("migrateSqliteToPglite skips when backup and pgdata exist", async () => { const dir = makeTempDir(); try { const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); writeFileSync(`${sqlitePath}.bak`, "backup"); + rmSync(resolvePgliteDataDir(sqlitePath), { recursive: true, force: true }); + mkdirSync(resolvePgliteDataDir(sqlitePath)); const result = await migrateSqliteToPglite({ sqlitePath }); @@ -248,6 +259,59 @@ test("migrateSqliteToPglite skips when only the retained SQLite backup remains", } }); +test("migrateSqliteToPglite returns sqlite_missing when only .bak exists without .pgdata", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + writeFileSync(`${sqlitePath}.bak`, "backup"); + + const result = await migrateSqliteToPglite({ sqlitePath }); + + assert.equal(result.status, "skipped"); + assert.equal(result.status === "skipped" ? result.reason : "", "sqlite_missing"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("migrateSqliteToPglite with keepSource preserves the SQLite source file", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + + const result = await migrateSqliteToPglite({ + sqlitePath, + keepSource: true, + }); + + assert.equal(result.status, "migrated"); + assert.equal(result.sqliteBackupPath, null); + assert.equal( + existsSync(sqlitePath), + true, + "SQLite source kept when keepSource is true", + ); + assert.equal( + existsSync(`${sqlitePath}.bak`), + false, + "no backup created when keepSource is true", + ); + + const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); + try { + const sessions = await pg.query<{ id: string }>( + "SELECT id FROM sessions", + ); + assert.equal(sessions.rows.length, 1); + } finally { + await pg.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("prepareAgentDashboardDatabaseStartup leaves SQLite live for the SQLite backend", async () => { const dir = makeTempDir(); try { @@ -267,7 +331,7 @@ test("prepareAgentDashboardDatabaseStartup leaves SQLite live for the SQLite bac } }); -test("prepareAgentDashboardDatabaseStartup migrates before selecting the PGlite backend", async () => { +test("prepareAgentDashboardDatabaseStartup kicks off background migration with PGlite backend", async () => { const dir = makeTempDir(); try { const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); @@ -279,30 +343,38 @@ test("prepareAgentDashboardDatabaseStartup migrates before selecting the PGlite }); assert.equal(result.backend, "pglite"); - assert.equal(result.migration.status, "migrated"); - assert.equal(existsSync(sqlitePath), false); - assert.equal(existsSync(`${sqlitePath}.bak`), true); + assert.equal(result.migration, undefined); + assert.ok(result.migrationPromise, "migration promise exists"); + const migration = await result.migrationPromise; + assert.equal(migration.status, "migrated"); + assert.equal( + existsSync(sqlitePath), + true, + "SQLite preserved for sync runtime", + ); + assert.equal(existsSync(`${sqlitePath}.bak`), false, "no backup created"); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("prepareAgentDashboardDatabaseStartup falls back to SQLite when PGlite migration fails", async () => { +test("prepareAgentDashboardDatabaseStartup logs failure without affecting SQLite when PGlite migration fails", async () => { const dir = makeTempDir(); try { const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - const pgliteDataDir = resolvePgliteDataDir(sqlitePath); - seedSqlite(sqlitePath); - writeFileSync(pgliteDataDir, "blocks PGlite directory creation"); + const db = new DatabaseSync(sqlitePath); + db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)"); + db.close(); const result = await prepareAgentDashboardDatabaseStartup({ userDataPath: dir, backend: "pglite", }); - assert.equal(result.backend, "sqlite"); - assert.equal(result.migration?.status, "failed"); - assert.equal(existsSync(sqlitePath), true, "fallback keeps SQLite live"); + assert.equal(result.backend, "pglite"); + const migration = await result.migrationPromise!; + assert.equal(migration.status, "failed"); + assert.equal(existsSync(sqlitePath), true, "failure keeps SQLite live"); assert.equal(existsSync(`${sqlitePath}.bak`), false); } finally { rmSync(dir, { recursive: true, force: true }); From 1e107ac20587a178aa208d8cf98dc49a3ea64580 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Sun, 7 Jun 2026 10:29:29 -0500 Subject: [PATCH 03/20] FEA-1550: Resolve PGlite migration review gaps - Preserve live SQLite when prior backup and PGlite data already exist. - Copy SQLite source rows in bounded batches instead of materializing entire tables. - Sanitize the remaining SQLite path in migration failure logs. - Add regression coverage for recreated SQLite, multi-batch event copy, and sanitized logs. Testing: Focused SQLite-to-PGlite migration tests, desktop typecheck, desktop lint, desktop build, and full desktop test suite passed. Risks: PGlite remains a prepared background copy while SQLite is the active runtime until async stores cut over. --- .../database/sqlite-to-pglite-migration.ts | 28 +++-- .../test/sqlite-to-pglite-migration.test.ts | 102 +++++++++++++++++- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts index 4c47cff4..71bf0b05 100644 --- a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts +++ b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts @@ -221,7 +221,6 @@ export async function migrateSqliteToPglite( } if (backupExists && pgdataExists) { - await rm(sqlitePath, { force: true }); return { status: "skipped", reason: "already_migrated", @@ -240,12 +239,14 @@ export async function migrateSqliteToPglite( ); try { sqlite = new DatabaseSync(sqlitePath); + sqlite.exec("BEGIN"); assertAllTablesManaged(sqlite); pglite = await PGlite.create(stagingDir); const sourceSchema = readSqliteSchema(sqlite); const sourceCounts = readSourceCounts(sqlite, sourceSchema); await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts); + sqlite.exec("COMMIT"); sqlite.close(); sqlite = null; @@ -285,8 +286,13 @@ export async function migrateSqliteToPglite( ); } } catch (error) { + try { + sqlite?.exec("ROLLBACK"); + } catch { + /* ignore rollback failure */ + } log( - `SQLite to PGlite migration failed: sqlite=${sqlitePath}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`, + `SQLite to PGlite migration failed: sqlite=${sanitizePath(sqlitePath)}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`, ); return { status: "failed", @@ -344,12 +350,19 @@ async function initializeAndCopy( if (columns.length === 0) { continue; } - const rows = sqlite - .prepare(`SELECT ${columns.join(", ")} FROM ${table.name}`) - .all() as Record[]; - for (let i = 0; i < rows.length; i += BATCH_SIZE) { - const batch = rows.slice(i, i + BATCH_SIZE); + const selectBatchStmt = sqlite.prepare( + `SELECT ${columns.join(", ")} FROM ${table.name} ORDER BY rowid LIMIT ? OFFSET ?`, + ); + let offset = 0; + while (true) { + const batch = selectBatchStmt.all(BATCH_SIZE, offset) as Record< + string, + unknown + >[]; + if (batch.length === 0) { + break; + } await batchInsertRows( tx, table.name, @@ -357,6 +370,7 @@ async function initializeAndCopy( columns, batch, ); + offset += batch.length; } } diff --git a/apps/desktop/test/sqlite-to-pglite-migration.test.ts b/apps/desktop/test/sqlite-to-pglite-migration.test.ts index e781b8b0..c8f5d845 100644 --- a/apps/desktop/test/sqlite-to-pglite-migration.test.ts +++ b/apps/desktop/test/sqlite-to-pglite-migration.test.ts @@ -175,6 +175,42 @@ function seedSqlite(dbPath: string): void { } } +function insertAdditionalEvents(dbPath: string, count: number): void { + const db = new DatabaseSync(dbPath); + try { + const insert = db.prepare(` + INSERT INTO events ( + id, session_id, agent_id, event_type, tool_name, summary, data, + created_at, user_id, organization_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + db.exec("BEGIN"); + try { + for (let i = 0; i < count; i += 1) { + insert.run( + `event-extra-${i}`, + "session-1", + "session-1-main", + "PostToolUse", + "Read", + `Batch event ${i}`, + JSON.stringify({ index: i }), + `2026-06-01T00:${String(i % 60).padStart(2, "0")}:00.000Z`, + "user-1", + "org-1", + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + } finally { + db.close(); + } +} + test("migrateSqliteToPglite copies rows, preserves attribution columns, and renames SQLite to .bak", async () => { const dir = makeTempDir(); try { @@ -216,6 +252,32 @@ test("migrateSqliteToPglite copies rows, preserves attribution columns, and rena } }); +test("migrateSqliteToPglite copies large tables in multiple bounded batches", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + insertAdditionalEvents(sqlitePath, 1000); + + const result = await migrateSqliteToPglite({ sqlitePath }); + + assert.equal(result.status, "migrated"); + assert.equal(result.status === "migrated" ? result.rowCounts.events : 0, 1001); + + const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); + try { + const eventCount = await pg.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM events", + ); + assert.equal(Number(eventCount.rows[0]?.count), 1001); + } finally { + await pg.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("migrateSqliteToPglite returns failed and leaves SQLite intact with unmanaged source tables", async () => { const dir = makeTempDir(); try { @@ -223,10 +285,20 @@ test("migrateSqliteToPglite returns failed and leaves SQLite intact with unmanag const db = new DatabaseSync(sqlitePath); db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)"); db.close(); + const messages: string[] = []; - const result = await migrateSqliteToPglite({ sqlitePath }); + const result = await migrateSqliteToPglite({ + sqlitePath, + log: (message) => messages.push(message), + }); assert.equal(result.status, "failed"); + assert.ok(messages.length > 0); + assert.equal( + messages.some((message) => message.includes(sqlitePath)), + false, + "failure logs must not include absolute SQLite paths", + ); assert.equal( existsSync(sqlitePath), true, @@ -259,6 +331,34 @@ test("migrateSqliteToPglite skips when backup and pgdata exist", async () => { } }); +test("migrateSqliteToPglite does not delete live SQLite when backup and pgdata already exist", async () => { + const dir = makeTempDir(); + try { + const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); + seedSqlite(sqlitePath); + writeFileSync(`${sqlitePath}.bak`, "backup"); + mkdirSync(resolvePgliteDataDir(sqlitePath)); + + const result = await migrateSqliteToPglite({ sqlitePath }); + + assert.equal(result.status, "skipped"); + assert.equal(result.status === "skipped" ? result.reason : "", "already_migrated"); + assert.equal(existsSync(sqlitePath), true, "live SQLite must not be deleted"); + + const db = new DatabaseSync(sqlitePath); + try { + const row = db.prepare("SELECT id FROM sessions WHERE id = ?").get("session-1") as + | { id: string } + | undefined; + assert.equal(row?.id, "session-1"); + } finally { + db.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("migrateSqliteToPglite returns sqlite_missing when only .bak exists without .pgdata", async () => { const dir = makeTempDir(); try { From 3b22e02153ac12d83d0642d3f5194c0cc2282323 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Sun, 7 Jun 2026 22:29:36 -0500 Subject: [PATCH 04/20] FEA-1550: Cut Agent Dashboard over to PGlite - Replace the design-system Agent Dashboard SQLite runtime with a fresh PGlite data directory - Remove the SQLite-to-PGlite migration startup path and migration tests - Add async PGlite-backed dashboard stores, hook writes, collector imports, session sync, and reconciliation reads - Cover fresh PGlite startup and first hook ingestion Testing: Desktop typecheck, lint, focused PGlite dashboard tests, sync tests, and boot-recovery test passed; full desktop suite progressed through touched coverage but was interrupted after hanging in boot-recovery, which passed in isolation Risks: Existing agent-dashboard.sqlite data is intentionally not migrated; the new PGlite database starts empty and repopulates from hooks and collectors after first start --- apps/desktop/package.json | 2 +- .../main/agent-dashboard-database-startup.ts | 93 - .../src/main/agent-dashboard-db-types.ts | 52 + .../agent-dashboard-design-system-runtime.ts | 93 +- .../src/main/agent-monitor-listener.ts | 24 +- .../src/main/agent-session-sync-service.ts | 136 +- apps/desktop/src/main/app.ts | 27 +- .../src/main/collectors/collector-manager.ts | 20 +- .../src/main/collectors/import-session.ts | 50 +- .../src/main/cost-reconciliation-service.ts | 4 +- apps/desktop/src/main/database/index.ts | 2 + apps/desktop/src/main/database/pglite.ts | 2014 +++++++++++++++++ .../database/sqlite-to-pglite-migration.ts | 590 ----- .../pglite-agent-dashboard-database.test.ts | 52 + .../test/sqlite-to-pglite-migration.test.ts | 503 ---- 15 files changed, 2286 insertions(+), 1376 deletions(-) delete mode 100644 apps/desktop/src/main/agent-dashboard-database-startup.ts create mode 100644 apps/desktop/src/main/agent-dashboard-db-types.ts create mode 100644 apps/desktop/src/main/database/pglite.ts delete mode 100644 apps/desktop/src/main/database/sqlite-to-pglite-migration.ts create mode 100644 apps/desktop/test/pglite-agent-dashboard-database.test.ts delete mode 100644 apps/desktop/test/sqlite-to-pglite-migration.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b3f0142f..a5f568c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.16.1", + "version": "0.16.0", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/agent-dashboard-database-startup.ts b/apps/desktop/src/main/agent-dashboard-database-startup.ts deleted file mode 100644 index 5028a1df..00000000 --- a/apps/desktop/src/main/agent-dashboard-database-startup.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { basename, join } from "node:path"; -import { - cleanupExpiredSqliteBackups, - migrateSqliteToPglite, - resolvePgliteDataDir, - type SqliteToPgliteMigrationResult, -} from "./database/sqlite-to-pglite-migration.js"; - -export type AgentDashboardDatabaseBackend = "sqlite" | "pglite"; - -export type AgentDashboardDatabaseStartupResult = - | { - backend: "sqlite"; - sqlitePath: string; - pgliteDataDir: string; - migration?: SqliteToPgliteMigrationResult; - } - | { - backend: "pglite"; - sqlitePath: string; - pgliteDataDir: string; - migration?: SqliteToPgliteMigrationResult; - migrationPromise: Promise; - }; - -export function resolveAgentDashboardDatabasePathForUserData( - userDataPath: string, -): string { - return join(userDataPath, "agent-dashboard.sqlite"); -} - -export async function prepareAgentDashboardDatabaseStartup(options: { - userDataPath: string; - backend: AgentDashboardDatabaseBackend; - log?: (scope: string, message: string) => void; -}): Promise { - const sqlitePath = resolveAgentDashboardDatabasePathForUserData( - options.userDataPath, - ); - const pgliteDataDir = resolvePgliteDataDir(sqlitePath); - const log = options.log ?? (() => {}); - - const removed = await cleanupExpiredSqliteBackups(sqlitePath); - if (removed > 0) { - log( - "agent-dashboard-migration", - `Removed ${removed} expired SQLite backup(s) for ${basename(sqlitePath)}`, - ); - } - - if (options.backend === "sqlite") { - return { backend: "sqlite", sqlitePath, pgliteDataDir }; - } - - const onSettled = (migration: SqliteToPgliteMigrationResult) => { - if (migration.status === "failed") { - log( - "agent-dashboard-migration", - `PGlite migration failed (runtime continues on SQLite): ${migration.error}`, - ); - } else if (migration.status === "migrated") { - log( - "agent-dashboard-migration", - `PGlite migration completed (${migration.rowCounts.sessions} sessions); SQLite preserved for sync runtime`, - ); - } - return migration; - }; - - const migrationPromise = migrateSqliteToPglite({ - sqlitePath, - pgliteDataDir, - keepSource: true, - log: (message) => log("agent-dashboard-migration", message), - }).then(onSettled, (error: unknown) => { - const err = error instanceof Error ? error.message : String(error); - log("agent-dashboard-migration", `PGlite migration failed: ${err}`); - return { - status: "failed" as const, - sqlitePath, - pgliteDataDir, - error: err, - failedAt: new Date().toISOString(), - }; - }); - - return { - backend: "pglite", - sqlitePath, - pgliteDataDir, - migrationPromise, - }; -} diff --git a/apps/desktop/src/main/agent-dashboard-db-types.ts b/apps/desktop/src/main/agent-dashboard-db-types.ts new file mode 100644 index 00000000..133ce23e --- /dev/null +++ b/apps/desktop/src/main/agent-dashboard-db-types.ts @@ -0,0 +1,52 @@ +import type { Harness, NormalizedSession } from "./collectors/types.js"; + +/** Snake_case hook payload `data` block as delivered by the hook handlers. */ +export interface HookData { + session_id?: string; + cwd?: string; + model?: string; + transcript_path?: string; + tool_name?: string; + tool_input?: Record | null; + source?: string; + stop_reason?: string; + message?: string; + agent_type?: string; + subagent_type?: string; + prompt?: string; + description?: string; + session_name?: string; + [key: string]: unknown; +} + +/** Cumulative per-model token counts from the current transcript segment. */ +export interface TokenUsageCounts { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +/** Effective reconciled per-(session, model) token counts. Internal: never crosses IPC. */ +export interface TokenUsageRow { + sessionId: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; +} + +export interface ImportResult { + /** True when the session already existed and nothing new was written. */ + skipped: boolean; + /** True when a terminal session was revived because its file is recently active. */ + reactivated: boolean; +} + +export interface Importer { + importSession( + session: NormalizedSession, + harness: Harness, + ): ImportResult | Promise; +} diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index fc378537..01760cfa 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -2,23 +2,17 @@ import path from "node:path"; import { app, ipcMain, type BrowserWindow } from "electron"; import { AgentHookListener } from "./agent-monitor-listener.js"; import { CollectorManager } from "./collectors/collector-manager.js"; -import { - loadMeteredUsageRows, - type MeteredUsageRow, -} from "./reconciliation-worker.js"; +import type { MeteredUsageRow } from "./reconciliation-worker.js"; +import type { AgentSessionSyncSource } from "./agent-session-sync-service.js"; import type { SessionPageRequest } from "../shared/agent-db-contract.js"; import { detectBillingMode } from "./billing-mode-detector.js"; -import { openAgentDatabase, type AgentDatabase } from "./database/index.js"; -import { coerceDbId } from "./database/ipc-validation.js"; -import { createLifecycle } from "./database/lifecycle.js"; import { - resolveAgentDashboardDatabasePathForUserData, - type AgentDashboardDatabaseStartupResult, -} from "./agent-dashboard-database-startup.js"; + openPgliteAgentDatabase, + type PgliteAgentDatabase, +} from "./database/pglite.js"; +import { coerceDbId } from "./database/ipc-validation.js"; import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js"; -export { prepareAgentDashboardDatabaseStartup } from "./agent-dashboard-database-startup.js"; - const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ "desktop:db:get-sessions", "desktop:db:get-sessions-page", @@ -43,11 +37,11 @@ export interface AgentDashboardDesignSystemRuntimeOptions { onTerminalFailure: (reason: string) => void; userDataPath?: string; log?: (scope: string, message: string) => void; - startupResult?: AgentDashboardDatabaseStartupResult; } export interface AgentDashboardDesignSystemRuntime { - connection: AgentDatabase["connection"]; + connection: null; + syncSource: AgentSessionSyncSource | null; getUrl: () => string | null; isReady: () => boolean; start: () => void; @@ -55,77 +49,57 @@ export interface AgentDashboardDesignSystemRuntime { close: () => void; restartCollectors: () => void; registerIpcHandlers: () => void; - loadMeteredUsageRows: (cutoffIso: string) => MeteredUsageRow[]; + loadMeteredUsageRows: (cutoffIso: string) => MeteredUsageRow[] | Promise; } /** * Resolve the opt-in design-system dashboard database. This helper lives inside * the dynamic boundary so default/legacy boot never imports code that can create - * or migrate `agent-dashboard.sqlite`. + * the PGlite data directory. */ export function resolveAgentDashboardDatabasePath( userDataPath = app.getPath("userData"), ): string { - return resolveAgentDashboardDatabasePathForUserData(userDataPath); + return path.join(userDataPath, "agent-dashboard.pgdata"); } /** * Create the in-process design-system dashboard runtime. Import this module only * after the Labs flag has selected design-system mode; all imports below this - * boundary can open SQLite, bind the hook port, register IPC, or start watchers. + * boundary can open PGlite, bind the hook port, register IPC, or start watchers. */ -export function createAgentDashboardDesignSystemRuntime( +export async function createAgentDashboardDesignSystemRuntime( options: AgentDashboardDesignSystemRuntimeOptions, -): AgentDashboardDesignSystemRuntime { +): Promise { const log = options.log ?? (() => {}); - const startupResult = options.startupResult; - - if (startupResult?.backend === "pglite") { - log( - "agent-dashboard-migration", - "PGlite migration kicked off in background; SQLite runtime active during migration", - ); - void startupResult.migrationPromise.then((migration) => { - if (migration.status === "failed") { - log( - "agent-dashboard-migration", - `PGlite migration failed: ${migration.error}`, - ); - } else { - log( - "agent-dashboard-migration", - `PGlite migration completed (${migration.status === "migrated" ? `${migration.rowCounts.sessions} sessions` : "skipped"})`, - ); - } - }); - } - - const agentDatabase = openAgentDatabase( - resolveAgentDashboardDatabasePath(options.userDataPath), - ); - let dbIpcRegistered = false; - let closed = false; - - const lifecycle = createLifecycle(agentDatabase.connection, { - tokenUsage: agentDatabase.tokenUsage, + let pgliteDatabase: PgliteAgentDatabase | null = null; + const agentDatabase = await openPgliteAgentDatabase({ + dataDir: resolveAgentDashboardDatabasePath(options.userDataPath), detectBillingMode, emit: (sessionId: string) => { - agentDatabase.sessions.handleSessionMutation(sessionId); + void pgliteDatabase?.sessions.handleSessionMutation(sessionId); options.getWindow()?.webContents.send("desktop:db:changed", { sessionId }); }, - log: (message: string) => log("agent-lifecycle", message), + log: (message: string) => log("agent-pglite", message), }); + pgliteDatabase = agentDatabase; + log( + "agent-dashboard", + "PGlite runtime active for Agent Dashboard database", + ); + let dbIpcRegistered = false; + let closed = false; const hookListener = new AgentHookListener({ - lifecycle, + lifecycle: { processEvent: agentDatabase.processEvent }, log: (message: string) => log("agent-monitor-listener", message), onBindError: options.onTerminalFailure, }); const collectorManager = new CollectorManager({ - agentDatabase, + importer: agentDatabase.importer, detectBillingMode, - stateDir: path.join(options.userDataPath ?? app.getPath("userData"), "agent-monitor"), + stateDir: path.join(options.userDataPath ?? app.getPath("userData"), "agent-dashboard-ingest"), emit: (sessionId?: string) => { options.getWindow()?.webContents.send("desktop:db:changed", { sessionId }); }, @@ -135,6 +109,7 @@ export function createAgentDashboardDesignSystemRuntime( const runtime: AgentDashboardDesignSystemRuntime = { connection: agentDatabase.connection, + syncSource: agentDatabase.syncSource, getUrl: () => hookListener.getUrl(), isReady: () => hookListener.isReady(), start: () => { @@ -157,7 +132,7 @@ export function createAgentDashboardDesignSystemRuntime( } closed = true; unregisterDesignSystemDbIpcHandlers(); - agentDatabase.close(); + void agentDatabase.close(); }, restartCollectors: () => { if (closed) { @@ -174,13 +149,13 @@ export function createAgentDashboardDesignSystemRuntime( registerDesignSystemDbIpcHandlers(agentDatabase); }, loadMeteredUsageRows: (cutoffIso: string) => - loadMeteredUsageRows(agentDatabase.connection, cutoffIso), + agentDatabase.loadMeteredUsageRows(cutoffIso), }; return runtime; } -function registerDesignSystemDbIpcHandlers(agentDatabase: AgentDatabase): void { +function registerDesignSystemDbIpcHandlers(agentDatabase: PgliteAgentDatabase): void { ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll()); ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) => @@ -253,7 +228,7 @@ function registerDesignSystemDbIpcHandlers(agentDatabase: AgentDatabase): void { ipcMain.handle("desktop:db:get-agent-hierarchy", (_event, sessionId: unknown) => { const id = coerceDbId(sessionId); if (id === null) return []; - return agentDatabase.agents.getBySessionWithChildren(id, agentDatabase.events); + return agentDatabase.agents.getBySessionWithChildren(id); }); ipcMain.handle("desktop:db:get-analytics", () => diff --git a/apps/desktop/src/main/agent-monitor-listener.ts b/apps/desktop/src/main/agent-monitor-listener.ts index 40ac87b8..bf958633 100644 --- a/apps/desktop/src/main/agent-monitor-listener.ts +++ b/apps/desktop/src/main/agent-monitor-listener.ts @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net"; import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod"; import { AGENT_MONITOR_PORT } from "../shared/contracts.js"; -import type { createLifecycle, HookData } from "./database/lifecycle.js"; +import type { HookData } from "./agent-dashboard-db-types.js"; // CLOSEDLOOP-TICKET FEA-1500: remove legacy HTTP hook listener on 4820 after // transport migration (FEA-1497 breaking-change discipline contract #1). The hook @@ -20,6 +20,14 @@ const CODEX_HOOK_EVENT_PATH = "/api/hooks/codex/event"; const PROVIDER_HINT_FIELD = "__provider"; type HookHarness = "claude" | "codex"; +export interface AgentHookLifecycle { + processEvent( + hookType: string, + data: HookData, + harness: string, + ): boolean | Promise; +} + /** The `{ hook_type, data }` envelope every hook handler POSTs. */ const HookEnvelopeSchema = z.object({ hook_type: z.string(), @@ -28,7 +36,7 @@ const HookEnvelopeSchema = z.object({ export interface AgentHookListenerOptions { /** The lifecycle processor that owns all DB writes. */ - lifecycle: ReturnType; + lifecycle: AgentHookLifecycle; /** Key-free diagnostic sink (gatewayLog). */ log?: (message: string) => void; /** @@ -169,8 +177,16 @@ export class AgentHookListener { return; } - this.options.lifecycle.processEvent(hookType, data, harness); - this.json(res, 200, { ok: true }); + Promise.resolve( + this.options.lifecycle.processEvent(hookType, data, harness), + ) + .then(() => this.json(res, 200, { ok: true })) + .catch((error: unknown) => { + this.log( + `agent hook listener: failed to process event: ${error instanceof Error ? error.message : String(error)}`, + ); + this.json(res, 200, { ok: false }); + }); } catch (error) { // Malformed JSON or unexpected error: ack 200 (fail-soft) + log. this.log( diff --git a/apps/desktop/src/main/agent-session-sync-service.ts b/apps/desktop/src/main/agent-session-sync-service.ts index 6af84331..5dd3ce39 100644 --- a/apps/desktop/src/main/agent-session-sync-service.ts +++ b/apps/desktop/src/main/agent-session-sync-service.ts @@ -55,7 +55,7 @@ export function estimateSessionPayloadBytes(session: SyncedAgentSession): number const { app } = electron; -type SessionCursorRow = { +export type SessionCursorRow = { id: string; updated_at: string; }; @@ -120,6 +120,18 @@ export type SessionAttributionResolverCache = { repoFullNameByPath: Map; }; +export interface AgentSessionSyncSource { + listAllSessionCursorRows(): SessionCursorRow[] | Promise; + listUpdatedSessionCursorRows( + sinceUpdatedAt: string, + ): SessionCursorRow[] | Promise; + loadSyncedSessions( + ids: string[], + cache: SessionAttributionResolverCache, + ): SyncedAgentSession[] | Promise; + close?: () => void | Promise; +} + export type AgentSessionSyncTelemetryEvent = { outcome: "failure"; reason: DesktopAgentSessionsAckReason; @@ -136,12 +148,13 @@ export interface AgentSessionSyncServiceOptions { sendBatch: (batch: AgentSessionSyncBatch) => Promise; getUserDataPath?: () => string; /** - * The optional design-system in-process DB connection. When provided, the - * service reads through it (no per-cycle open/close, no path resolution, no - * existsSync guard). When omitted or null, the service falls back to the - * legacy sidecar dashboard.db path from getUserDataPath. + * Optional SQLite compatibility source. When omitted or null and getSource is + * also absent, the service falls back to the legacy sidecar dashboard.db path + * from getUserDataPath. */ getConnection?: () => DatabaseSync | null; + /** Optional live dashboard source for async backends such as PGlite. */ + getSource?: () => AgentSessionSyncSource | null; onBatchOutcome?: (event: AgentSessionSyncTelemetryEvent) => void; } @@ -289,11 +302,12 @@ export class AgentSessionSyncService { this.syncing = true; try { - // The caller owns the dashboard source by mode: design-system injects an - // in-process connection, legacy falls back to dashboard.db on disk, and - // disabled mode stops this service before it reaches the source lookup. + // The caller owns the dashboard source by mode: design-system injects a + // live source, legacy falls back to dashboard.db on disk, and disabled + // mode stops this service before it reaches the source lookup. const sharedDb = this.options.getConnection?.() ?? null; - const dbPath = sharedDb + const injectedSource = this.options.getSource?.() ?? null; + const dbPath = sharedDb || injectedSource ? null : resolveAgentMonitorDatabasePath(this.options.getUserDataPath?.()); if (dbPath && !existsSync(dbPath)) { @@ -331,13 +345,13 @@ export class AgentSessionSyncService { syncIds = [sessionId]; // Skip DB access — go straight to send. } else { - const db = sharedDb ?? new DatabaseSync(dbPath!); + const source = injectedSource ?? createSqliteSessionSyncSource( + sharedDb ?? new DatabaseSync(dbPath!), + !sharedDb, + ); try { - if (!sharedDb) { - db.exec("PRAGMA busy_timeout = 5000"); - } - this.initializeBackfillQueueIfNeeded(db); - this.enqueueIncrementalUpdates(db); + await this.initializeBackfillQueueIfNeeded(source); + await this.enqueueIncrementalUpdates(source); const nowMs = Date.now(); let candidateIds: string[] = []; @@ -384,12 +398,12 @@ export class AgentSessionSyncService { const chunkedSyncEnabled = this.options.isChunkedSyncEnabled?.() ?? false; - // Load all candidate sessions from SQLite, then accumulate into the - // batch until adding the next session would exceed the 256 KiB cap. + // Load all candidate sessions from the selected dashboard source, + // then accumulate into the batch until adding the next session would + // exceed the 256 KiB cap. // Sessions that individually exceed the cap are either chunked (if // the feature flag is on) or skipped/dead-lettered. - const rawSessions = loadSyncedSessions( - db, + const rawSessions = await source.loadSyncedSessions( candidateIds, this.attributionCache, ); @@ -475,9 +489,7 @@ export class AgentSessionSyncService { sessions, }; } finally { - if (!sharedDb) { - db.close(); - } + await source.close?.(); } } @@ -511,12 +523,14 @@ export class AgentSessionSyncService { } } - private initializeBackfillQueueIfNeeded(db: DatabaseSync): void { + private async initializeBackfillQueueIfNeeded( + source: AgentSessionSyncSource, + ): Promise { if (this.observedTopUpdatedAt !== null) { return; } - const rows = listAllSessionCursorRows(db); + const rows = await source.listAllSessionCursorRows(); if (rows.length === 0) { return; } @@ -540,14 +554,16 @@ export class AgentSessionSyncService { ); } - private enqueueIncrementalUpdates(db: DatabaseSync): void { + private async enqueueIncrementalUpdates( + source: AgentSessionSyncSource, + ): Promise { if (!this.observedTopUpdatedAt) { return; } const previousTopUpdatedAt = this.observedTopUpdatedAt; const previousTopIds = new Set(this.observedIdsAtTopUpdatedAt); - const rows = listUpdatedSessionCursorRows(db, previousTopUpdatedAt); + const rows = await source.listUpdatedSessionCursorRows(previousTopUpdatedAt); if (rows.length === 0) { return; } @@ -836,9 +852,14 @@ export function sanitizeSessionForSync( } const STRIPPED_LEAF_KEYS = new Set([ - "prompt", "content", "stdout", "stderr", - "text", "output", "reasoning", - "old_string", "new_string", "patch", "command", "arguments", + "command", + "content", + "output", + "prompt", + "reasoning", + "stderr", + "stdout", + "text", ]); function stripDataContent(data: SyncJsonValue | undefined): SyncJsonValue | undefined { @@ -868,6 +889,29 @@ export function resolveAgentMonitorDatabasePath( return path.join(userDataPath, "agent-monitor", "dashboard.db"); } +function createSqliteSessionSyncSource( + db: DatabaseSync, + ownsConnection: boolean, +): AgentSessionSyncSource { + if (ownsConnection) { + db.exec("PRAGMA busy_timeout = 5000"); + } + return { + listAllSessionCursorRows: () => listAllSessionCursorRows(db), + listUpdatedSessionCursorRows: (sinceUpdatedAt: string) => + listUpdatedSessionCursorRows(db, sinceUpdatedAt), + loadSyncedSessions: ( + ids: string[], + cache: SessionAttributionResolverCache, + ) => loadSyncedSessions(db, ids, cache), + close: () => { + if (ownsConnection) { + db.close(); + } + }, + }; +} + export function listAllSessionCursorRows(db: DatabaseSync): SessionCursorRow[] { return db .prepare( @@ -923,12 +967,6 @@ export function loadSyncedSessions( return []; } - const hasIdentityCols = - columnExists(db, "sessions", "user_id") - && columnExists(db, "sessions", "organization_id"); - const identityColsSql = hasIdentityCols - ? "user_id, organization_id" - : "NULL AS user_id, NULL AS organization_id"; const sessionRows = selectRowsByIds( db, ` @@ -945,7 +983,8 @@ export function loadSyncedSessions( metadata, harness, billing_mode, - ${identityColsSql} + user_id, + organization_id FROM sessions WHERE id IN (__IDS__) `, @@ -1052,9 +1091,9 @@ export function loadSyncedSessions( endedAt: row.ended_at, awaitingInputSince: row.awaiting_input_since, metadata: parseJsonObjectText(row.metadata), + ...(row.user_id ? { userId: row.user_id } : {}), + ...(row.organization_id ? { organizationId: row.organization_id } : {}), ...(attribution ? { attribution } : {}), - ...(row.user_id != null ? { userId: row.user_id } : {}), - ...(row.organization_id != null ? { organizationId: row.organization_id } : {}), agents: (agentsBySessionId.get(id) ?? []).map((agentRow) => ({ externalAgentId: agentRow.id, name: agentRow.name, @@ -1113,24 +1152,15 @@ export function estimateTokenUsageCostUsd( * 'unknown') by best-effort detecting from the live desktop environment. A * stored, definite mode always wins over re-detection. */ -export function resolveBillingModeForRow(row: SessionRow): BillingMode { +export function resolveBillingModeForRow( + row: Pick, +): BillingMode { return resolveBillingMode({ billingMode: row.billing_mode, harness: row.harness, }); } -function columnExists( - db: DatabaseSync, - table: string, - column: string, -): boolean { - const rows = db - .prepare(`PRAGMA table_info(${table})`) - .all() as Array<{ name: string }>; - return rows.some((row) => row.name === column); -} - function selectRowsByIds( db: DatabaseSync, sql: string, @@ -1157,7 +1187,7 @@ function groupRowsBySessionId< return grouped; } -function resolveSessionAttribution( +export function resolveSessionAttribution( cwd: string | null, cache: SessionAttributionResolverCache, ): SyncedAgentSessionAttribution | undefined { @@ -1238,7 +1268,7 @@ function findLaunchMetadataRoot( } } -function parseJsonValueText(value: string | null): SyncJsonValue | null { +export function parseJsonValueText(value: string | null): SyncJsonValue | null { if (!value || value.trim().length === 0) { return null; } @@ -1250,7 +1280,7 @@ function parseJsonValueText(value: string | null): SyncJsonValue | null { } } -function parseJsonObjectText(value: string | null): SyncJsonObject | null { +export function parseJsonObjectText(value: string | null): SyncJsonObject | null { const parsed = parseJsonValueText(value); return isSyncJsonObject(parsed) ? parsed : null; } diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 004bec38..e8819d39 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -704,6 +704,10 @@ export class DesktopApplication { this.agentDashboardMode === "design-system" ? this.agentDashboardDesignSystem?.connection ?? null : null, + getSource: () => + this.agentDashboardMode === "design-system" + ? this.agentDashboardDesignSystem?.syncSource ?? null + : null, onBatchOutcome: (event) => { Observability.agentSessionSyncBatchFailed(event); }, @@ -713,8 +717,8 @@ export class DesktopApplication { // renderer) and the reconciliation store, and reconciles the local // genai-prices estimate against what each vendor actually billed. // Agent Dashboard usage rows are mode-owned: legacy reads dashboard.db from - // disk, design-system reads the in-process connection, and disabled returns - // no rows so the dashboard code path stays inert. + // disk, design-system reads the PGlite runtime, and disabled returns no rows + // so the dashboard code path stays inert. // One Anthropic Admin key store, shared by reconciliation (compares the local // estimate against the billed cost_report) and Claude Code analytics (reads // Anthropic's own per-user usage estimate). Sharing the store means a key @@ -1482,15 +1486,15 @@ export class DesktopApplication { return false; } - private loadAgentDashboardMeteredUsageRows(): MeteredUsageRow[] { + private async loadAgentDashboardMeteredUsageRows(): Promise { if (!this.isAgentMonitorEnabled()) { return []; } const cutoffIso = reconciliationCutoffIso(new Date()); if (this.agentDashboardMode === "design-system") { - return ( - this.agentDashboardDesignSystem?.loadMeteredUsageRows(cutoffIso) ?? [] + return await Promise.resolve( + this.agentDashboardDesignSystem?.loadMeteredUsageRows(cutoffIso) ?? [], ); } @@ -1509,19 +1513,11 @@ export class DesktopApplication { return null; } if (!this.agentDashboardDesignSystem) { - const { - createAgentDashboardDesignSystemRuntime, - prepareAgentDashboardDatabaseStartup, - } = await import( + const { createAgentDashboardDesignSystemRuntime } = await import( "./agent-dashboard-design-system-runtime.js" ); - const startupResult = await prepareAgentDashboardDatabaseStartup({ - userDataPath: app.getPath("userData"), - backend: "pglite", - log: (scope, message) => gatewayLog.info(scope, message), - }); this.agentDashboardDesignSystem = - createAgentDashboardDesignSystemRuntime({ + await createAgentDashboardDesignSystemRuntime({ userDataPath: app.getPath("userData"), getWindow: () => this.desktopWindow.getWindow(), onTerminalFailure: (reason) => { @@ -1535,7 +1531,6 @@ export class DesktopApplication { this.refreshTrayState(); }, log: (scope, message) => gatewayLog.info(scope, message), - startupResult, }); this.agentDashboardDesignSystem.registerIpcHandlers(); } diff --git a/apps/desktop/src/main/collectors/collector-manager.ts b/apps/desktop/src/main/collectors/collector-manager.ts index 17ebfa2d..812ae074 100644 --- a/apps/desktop/src/main/collectors/collector-manager.ts +++ b/apps/desktop/src/main/collectors/collector-manager.ts @@ -2,8 +2,8 @@ * @file collector-manager.ts * @description Owns the in-process multi-harness collection layer (FEA-1503): * boot-time bulk import + live file watchers for all five agent CLIs (Claude, - * Codex, Cursor, Copilot, OpenCode), writing through the first-party - * `importSession` into the shared in-process DB. Started/stopped alongside the + * Codex, Cursor, Copilot, OpenCode), writing through the injected importer into + * the shared in-process DB. Started/stopped alongside the * hook listener via the `agentMonitorEnabled` toggle. * * Local import is ungated — all sessions from all five harnesses are imported @@ -15,8 +15,7 @@ * double-count turns). Claude boot historical import still runs and is idempotent * against any hook-written events. */ -import type { AgentDatabase } from "../database/index.js"; -import { createImporter, type Importer } from "./import-session.js"; +import type { Importer } from "../agent-dashboard-db-types.js"; import { createCatchupCache, type CatchupCache } from "./catchup-cache.js"; import { ingestCachePath, ingestOpencodeFingerprintPath } from "./ingest-paths.js"; import { createHarnessWatcher, type HarnessWatcher } from "./watcher.js"; @@ -28,10 +27,10 @@ import { createCopilotCollector } from "./copilot/copilot-collector.js"; import { createOpencodeCollector } from "./opencode/opencode-collector.js"; export interface CollectorManagerOptions { - agentDatabase: AgentDatabase; + importer: Importer; /** Resolve a billing mode for a harness at session creation (FEA-1434). */ detectBillingMode: (harness: string) => string; - /** Durable dir for persisted catchup caches (e.g. userData/agent-monitor). */ + /** Durable dir for persisted catchup caches. */ stateDir: string; /** Push a renderer live-update after an import batch wrote rows. */ emit: (sessionId?: string) => void; @@ -61,12 +60,7 @@ export class CollectorManager { constructor(options: CollectorManagerOptions) { this.options = options; this.log = options.log ?? (() => {}); - this.importer = createImporter(options.agentDatabase.connection, { - tokenUsage: options.agentDatabase.tokenUsage, - detectBillingMode: options.detectBillingMode, - now: options.now, - log: this.log, - }); + this.importer = options.importer; this.collectors = options.collectors ?? defaultCollectors(options.stateDir); for (const collector of this.collectors) { if (!collector.batch) { @@ -160,7 +154,7 @@ export class CollectorManager { for (const session of sessions) { if (this.stopped) break; - const result = this.importer.importSession(session, collector.key); + const result = await this.importer.importSession(session, collector.key); if (!(result.skipped && !result.reactivated)) imported++; } diff --git a/apps/desktop/src/main/collectors/import-session.ts b/apps/desktop/src/main/collectors/import-session.ts index 09fa314c..ef0ad801 100644 --- a/apps/desktop/src/main/collectors/import-session.ts +++ b/apps/desktop/src/main/collectors/import-session.ts @@ -39,11 +39,6 @@ export interface ImporterDeps { now?: () => string; /** Key-free diagnostic sink. */ log?: (message: string) => void; - /** - * FEA-1548: resolve the current authenticated user's identity for stamping - * on new sessions. Returns null when no user is signed in. - */ - getUserIdentity?: () => { userId: string; organizationId: string | null } | null; } export interface ImportResult { @@ -60,7 +55,10 @@ interface SessionRowRaw { } export interface Importer { - importSession(session: NormalizedSession, harness: Harness): ImportResult; + importSession( + session: NormalizedSession, + harness: Harness, + ): ImportResult | Promise; } export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { @@ -71,11 +69,10 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { "SELECT id, status, ended_at FROM sessions WHERE id = ?", ); const insertSessionStmt = db.prepare(` - INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, ended_at, harness, billing_mode, metadata, user_id, organization_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, ended_at, harness, billing_mode, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // Fill only missing fields on an existing row; never clobber a live status. - // Always refresh metadata so new fields (diffStats, artifacts, etc.) are populated. const coalesceSessionStmt = db.prepare(` UPDATE sessions SET name = COALESCE(name, ?), @@ -151,10 +148,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { speeds: [], inference_geos: [], }, - diffStats: session.diffStats ?? null, - slashCommands: session.slashCommands ?? [], - artifacts: session.artifacts ?? { prs: [], issues: [], repo: null }, - tokenSeries: session.tokenSeries ?? [], }); } @@ -236,7 +229,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { if (!existing) { const status = recentlyActive ? "active" : "completed"; const billingMode = safe(() => deps.detectBillingMode(harness)) ?? "unknown"; - const identity = safe(() => deps.getUserIdentity?.()) ?? null; insertSessionStmt.run( session.sessionId, session.name ?? null, @@ -249,8 +241,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { harness, billingMode, buildMetadata(session, harness), - identity?.userId ?? null, - identity?.organizationId ?? null, ); insertAgentStmt.run( mainId, @@ -332,19 +322,7 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { addEvent("Stop", mainId, ts, null, null, null); } - for (const msg of session.messages ?? []) { - const eventType = msg.role === "human" ? "UserMessage" : "AssistantMessage"; - addEvent(eventType, mainId, msg.timestamp, null, null, eventData({ - text: msg.text, - role: msg.role, - ...(msg.model ? { model: msg.model } : {}), - ...(msg.tokens ? { tokens: msg.tokens } : {}), - ...(msg.isThinking ? { isThinking: true } : {}), - })); - } - (session.toolUses ?? []).forEach((tu, idx) => { - const enrichedData = buildToolEventData(tu); if (tu.name === "Agent" || tu.name === "Task") { const subId = `${session.sessionId}-sub-${idx}`; const input = (tu.input ?? {}) as Record; @@ -360,9 +338,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { tu.timestamp ?? sourceUpdatedAt, mainId, ); - addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", eventData(enrichedData)); + addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", eventData(tu.input)); } else { - addEvent("PostToolUse", mainId, tu.timestamp, tu.name, null, eventData(enrichedData)); + addEvent("PostToolUse", mainId, tu.timestamp, tu.name, null, eventData(tu.input)); } }); @@ -405,18 +383,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer { return { importSession }; } -function buildToolEventData(tu: NormalizedToolUse): Record { - const data: Record = {}; - if (tu.input != null) data.input = tu.input; - if (tu.output != null) data.output = tu.output; - if (tu.isError != null) data.isError = tu.isError; - if (tu.mcpServer != null) data.mcpServer = tu.mcpServer; - if (tu.mcpMethod != null) data.mcpMethod = tu.mcpMethod; - if (tu.skillName != null) data.skillName = tu.skillName; - if (tu.diffDelta != null) data.diffDelta = tu.diffDelta; - return data; -} - function strOf(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/apps/desktop/src/main/cost-reconciliation-service.ts b/apps/desktop/src/main/cost-reconciliation-service.ts index 02ee79ac..0e87e2b5 100644 --- a/apps/desktop/src/main/cost-reconciliation-service.ts +++ b/apps/desktop/src/main/cost-reconciliation-service.ts @@ -118,7 +118,7 @@ export interface CostReconciliationServiceDeps { openaiKeyStore: AdminKeyStoreLike; store: Pick; /** Load the metered usage rows to reconcile (production opens dashboard.db). */ - loadUsageRows: () => MeteredUsageRow[]; + loadUsageRows: () => MeteredUsageRow[] | Promise; /** Build the Anthropic cost client from a key (overridable in tests). */ createAnthropicClient?: (apiKey: string) => AnthropicCostClient; /** Build the OpenAI cost client from a key (overridable in tests). */ @@ -288,7 +288,7 @@ export class CostReconciliationService { } // Load usage once and reuse it across each vendor's pass. - const usageRows = this.deps.loadUsageRows(); + const usageRows = await this.deps.loadUsageRows(); const loadUsageRows = (): MeteredUsageRow[] => usageRows; let rowsWritten = 0; diff --git a/apps/desktop/src/main/database/index.ts b/apps/desktop/src/main/database/index.ts index aeb19cf5..532e0367 100644 --- a/apps/desktop/src/main/database/index.ts +++ b/apps/desktop/src/main/database/index.ts @@ -10,6 +10,7 @@ import { createDashboardQueries } from "./dashboard.js"; import type { DashboardSummary } from "../../shared/agent-db-contract.js"; export interface AgentDatabase { + backend: "sqlite"; /** * The underlying single shared connection. All in-process access — hook * writes (lifecycle), IPC reads, the cloud relay, and cost reconciliation — @@ -76,6 +77,7 @@ export function openAgentDatabase(dbPath: string): AgentDatabase { const dashboard = createDashboardQueries(db); return { + backend: "sqlite", connection: db, sessions, agents, diff --git a/apps/desktop/src/main/database/pglite.ts b/apps/desktop/src/main/database/pglite.ts new file mode 100644 index 00000000..66743dcf --- /dev/null +++ b/apps/desktop/src/main/database/pglite.ts @@ -0,0 +1,2014 @@ +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { PGlite, type Results } from "@electric-sql/pglite"; +import { + type AgentHierarchyNode, + type AgentRow, + type AnalyticsData, + type DashboardSummary, + type EventCountByType, + type EventRow, + type EventWithSession, + type KanbanPages, + type SessionPage, + type SessionPageRequest, + type SessionRow, + type SessionWithAgents, + type TokenAnalytics, + type WorkflowQueryData, +} from "../../shared/agent-db-contract.js"; +import { isMeteredApi } from "../../shared/billing-mode.js"; +import { resolveBillingMode } from "../billing-mode-detector.js"; +import type { MeteredUsageRow } from "../reconciliation-worker.js"; +import { + estimateTokenUsageCostUsd, + parseJsonObjectText, + parseJsonValueText, + resolveBillingModeForRow, + resolveSessionAttribution, + type AgentSessionSyncSource, + type SessionAttributionResolverCache, + type SessionCursorRow, +} from "../agent-session-sync-service.js"; +import type { + SyncedAgentSession, + SyncedAgentSessionTokenUsage, +} from "../agent-session-sync-contract.js"; +import type { + Harness, + NormalizedSession, + NormalizedToolUse, +} from "../collectors/types.js"; +import type { ImportResult, Importer } from "../collectors/import-session.js"; +import { + extractTranscriptTokens, + type TranscriptExtract, +} from "./transcript.js"; +import type { HookData } from "./lifecycle.js"; +import type { TokenUsageCounts } from "./token-usage.js"; +import type { TokenUsageRow } from "./types.js"; + +const TERMINAL_STATUSES = "('completed', 'abandoned', 'error')"; +const TERMINAL_STATUS_SET = new Set(["completed", "abandoned", "error"]); +const MAX_SESSION_PAGE_LIMIT = 100; +const DEFAULT_SESSION_PAGE_LIMIT = 25; +const COMPACTION_RE = /compact|compress|context.*(reduc|truncat|summar)/i; +const WAITING_INPUT_RE = + /needs your permission|waiting for your input|is waiting|requires approval|permission to use/i; +const RECENT_ACTIVITY_MS = 10 * 60 * 1000; +const MAX_EVENT_DATA_BYTES = 64 * 1024; + +const PGLITE_SCHEMA = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT, + status TEXT NOT NULL DEFAULT 'running', + cwd TEXT, + model TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + metadata TEXT, + harness TEXT, + billing_mode TEXT, + user_id TEXT, + organization_id TEXT +); + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + name TEXT, + type TEXT, + subagent_type TEXT, + status TEXT NOT NULL DEFAULT 'running', + task TEXT, + current_tool TEXT, + started_at TEXT, + updated_at TEXT, + ended_at TEXT, + awaiting_input_since TEXT, + parent_agent_id TEXT, + metadata TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id); +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); +CREATE INDEX IF NOT EXISTS idx_agents_type ON agents(type); +CREATE INDEX IF NOT EXISTS idx_agents_parent ON agents(parent_agent_id) WHERE parent_agent_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + agent_id TEXT, + event_type TEXT NOT NULL, + tool_name TEXT, + summary TEXT, + data TEXT, + created_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id); +CREATE INDEX IF NOT EXISTS idx_events_agent_id ON events(agent_id); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); +CREATE INDEX IF NOT EXISTS idx_events_tool_name ON events(tool_name) WHERE tool_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_events_session_tool ON events(session_id, created_at) WHERE tool_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); +CREATE INDEX IF NOT EXISTS idx_events_tool_created ON events(created_at, tool_name) WHERE tool_name IS NOT NULL; + +CREATE TABLE IF NOT EXISTS token_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + raw_input INTEGER NOT NULL DEFAULT 0, + raw_output INTEGER NOT NULL DEFAULT 0, + raw_cache_read INTEGER NOT NULL DEFAULT 0, + raw_cache_write INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (now()::text), + updated_at TEXT, + PRIMARY KEY (session_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_token_usage_session ON token_usage(session_id); +CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_status_started_at ON sessions(status, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sessions_organization_id ON sessions(organization_id) WHERE organization_id IS NOT NULL; +`; + +interface PgliteExecutor { + exec(query: string): Promise; + query = Record>( + query: string, + params?: unknown[], + ): Promise>; +} + +interface PgliteClient extends PgliteExecutor { + transaction(callback: (tx: PgliteExecutor) => Promise): Promise; + close(): Promise; +} + +interface SessionRowRaw extends Record { + id: string; + status: string; + harness: string | null; + billing_mode: string | null; + model: string | null; +} + +interface AgentRowRaw extends Record { + id: string; + status: string; + type: string | null; + parent_agent_id: string | null; +} + +export interface PgliteAgentDatabase { + backend: "pglite"; + connection: null; + importer: Importer; + syncSource: AgentSessionSyncSource; + sessions: { + getById(id: string): Promise; + getAll(): Promise; + getActive(): Promise; + getDetailsById(id: string): Promise; + getActiveWithDetails(): Promise; + getHistoricalWithDetails(): Promise; + getAllWithDetails(): Promise; + getPage(request?: SessionPageRequest): Promise; + getKanbanPages(statuses: string[], limit: number): Promise; + invalidateHistoricalDetails(): void; + handleSessionMutation(sessionId: string): Promise; + }; + agents: { + getBySession(sessionId: string): Promise; + getBySessionWithChildren(sessionId: string): Promise; + }; + events: { + getBySession(sessionId: string): Promise; + getBySessionAndAgent(sessionId: string, agentId: string): Promise; + getAll(): Promise; + getWithSession(sessionId: string): Promise; + getCountByType(): Promise; + }; + tokenUsage: { + replace( + sessionId: string, + model: string, + counts: TokenUsageCounts, + now: string, + tx?: PgliteExecutor, + ): Promise; + getBySession(sessionId: string): Promise; + }; + dashboard: { + getTokenAnalytics(): Promise; + getAnalytics(): Promise; + getWorkflowData(): Promise; + }; + getSummary(): Promise; + run(sql: string, ...params: unknown[]): Promise; + processEvent(hookType: string, data: HookData, harness: string): Promise; + loadMeteredUsageRows(cutoffIso: string): Promise; + close(): Promise; +} + +export interface OpenPgliteAgentDatabaseOptions { + dataDir: string; + detectBillingMode: (harness: string) => string; + emit?: (sessionId: string) => void; + extractTranscript?: (path: string) => TranscriptExtract | null; + log?: (message: string) => void; + now?: () => string; + staleMinutes?: number; +} + +export async function openPgliteAgentDatabase( + options: OpenPgliteAgentDatabaseOptions, +): Promise { + await mkdir(path.dirname(options.dataDir), { recursive: true }); + const db = await PGlite.create(options.dataDir) as PgliteClient; + await db.exec(PGLITE_SCHEMA); + + const log = options.log ?? (() => {}); + const nowFn = options.now ?? (() => new Date().toISOString()); + const tokenUsage = createPgliteTokenUsageStore(db); + const events = createPgliteEventStore(db); + const sessions = createPgliteSessionStore(db); + const agents = createPgliteAgentStore(db, events); + const dashboard = createPgliteDashboardQueries(db); + const queue = createWriteQueue(); + + const database: PgliteAgentDatabase = { + backend: "pglite", + connection: null, + importer: createPgliteImporter(db, queue, tokenUsage, { + detectBillingMode: options.detectBillingMode, + now: nowFn, + log, + }), + syncSource: createPgliteSessionSyncSource(db), + sessions, + agents, + events, + tokenUsage, + dashboard, + getSummary: () => dashboard.getSummary(), + run: async (sql: string, ...params: unknown[]) => { + await db.query(sql, params); + }, + processEvent: createPgliteLifecycle(db, queue, tokenUsage, { + detectBillingMode: options.detectBillingMode, + emit: options.emit, + extractTranscript: options.extractTranscript, + log, + now: nowFn, + staleMinutes: options.staleMinutes, + }).processEvent, + loadMeteredUsageRows: (cutoffIso: string) => + loadPgliteMeteredUsageRows(db, cutoffIso), + close: () => db.close(), + }; + + return database; +} + +function createWriteQueue() { + let tail = Promise.resolve(); + return { + run(fn: () => Promise): Promise { + const next = tail.then(fn, fn); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; + }, + }; +} + +function createPgliteSessionStore(db: PgliteClient) { + let historicalDetailsCache: SessionWithAgents[] | null = null; + + return { + async getById(id: string): Promise { + const result = await db.query("SELECT * FROM sessions WHERE id = $1", [id]); + return toSessionRow(result.rows[0]); + }, + async getAll(): Promise { + const result = await db.query("SELECT * FROM sessions ORDER BY started_at DESC"); + return result.rows.map(toSessionRow).filter(Boolean) as SessionRow[]; + }, + async getActive(): Promise { + const result = await db.query( + `SELECT * FROM sessions WHERE status NOT IN ${TERMINAL_STATUSES} ORDER BY started_at DESC`, + ); + return result.rows.map(toSessionRow).filter(Boolean) as SessionRow[]; + }, + async getDetailsById(id: string): Promise { + const result = await db.query(`${sessionDetailsCtes()} + SELECT + s.*, + COALESCE(ac.agent_count, 0)::int as agent_count, + COALESCE(ec.event_count, 0)::int as event_count, + COALESCE(tt.total_tokens, 0)::int as total_tokens + FROM sessions s + LEFT JOIN agent_counts ac ON ac.session_id = s.id + LEFT JOIN event_counts ec ON ec.session_id = s.id + LEFT JOIN token_totals tt ON tt.session_id = s.id + WHERE s.id = $1 + `, [id]); + return detailRowsToList(result.rows)[0]; + }, + async getActiveWithDetails(): Promise { + const result = await db.query(`${sessionDetailsCtes()} + SELECT + s.*, + COALESCE(ac.agent_count, 0)::int as agent_count, + COALESCE(ec.event_count, 0)::int as event_count, + COALESCE(tt.total_tokens, 0)::int as total_tokens + FROM sessions s + LEFT JOIN agent_counts ac ON ac.session_id = s.id + LEFT JOIN event_counts ec ON ec.session_id = s.id + LEFT JOIN token_totals tt ON tt.session_id = s.id + WHERE s.status NOT IN ${TERMINAL_STATUSES} + ORDER BY s.started_at DESC + `); + return detailRowsToList(result.rows); + }, + async getHistoricalWithDetails(): Promise { + if (historicalDetailsCache) { + return historicalDetailsCache; + } + const result = await db.query(`${sessionDetailsCtes()} + SELECT + s.*, + COALESCE(ac.agent_count, 0)::int as agent_count, + COALESCE(ec.event_count, 0)::int as event_count, + COALESCE(tt.total_tokens, 0)::int as total_tokens + FROM sessions s + LEFT JOIN agent_counts ac ON ac.session_id = s.id + LEFT JOIN event_counts ec ON ec.session_id = s.id + LEFT JOIN token_totals tt ON tt.session_id = s.id + WHERE s.status IN ${TERMINAL_STATUSES} + ORDER BY s.started_at DESC + `); + historicalDetailsCache = detailRowsToList(result.rows); + return historicalDetailsCache; + }, + async getAllWithDetails(): Promise { + return [ + ...await this.getActiveWithDetails(), + ...await this.getHistoricalWithDetails(), + ]; + }, + async getPage(request?: SessionPageRequest): Promise { + const { limit, offset, status, q } = coercePageRequest(request); + const { whereSql, params } = pageWhereClause(status, q); + const totalResult = await db.query<{ count: number }>( + `SELECT COUNT(*)::int as count FROM sessions s ${whereSql}`, + params, + ); + const rowsResult = await db.query(`${sessionDetailsCtes()} + SELECT + s.*, + COALESCE(ac.agent_count, 0)::int as agent_count, + COALESCE(ec.event_count, 0)::int as event_count, + COALESCE(tt.total_tokens, 0)::int as total_tokens + FROM sessions s + LEFT JOIN agent_counts ac ON ac.session_id = s.id + LEFT JOIN event_counts ec ON ec.session_id = s.id + LEFT JOIN token_totals tt ON tt.session_id = s.id + ${whereSql} + ORDER BY s.started_at DESC, s.id DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `, [...params, limit, offset]); + + return { + sessions: detailRowsToList(rowsResult.rows), + total: Number(totalResult.rows[0]?.count ?? 0), + limit, + offset, + }; + }, + async getKanbanPages(statuses: string[], limit: number): Promise { + const result: KanbanPages = {}; + for (const status of statuses) { + result[status] = await this.getPage({ limit, status }); + } + return result; + }, + invalidateHistoricalDetails(): void { + historicalDetailsCache = null; + }, + async handleSessionMutation(sessionId: string): Promise { + const session = await this.getById(sessionId); + if (!session || TERMINAL_STATUS_SET.has(session.status)) { + historicalDetailsCache = null; + } + }, + }; +} + +function coercePageRequest(request: SessionPageRequest | undefined): { + limit: number; + offset: number; + status: string | null; + q: string | null; +} { + const requestedLimit = request?.limit; + const limit = typeof requestedLimit === "number" && Number.isInteger(requestedLimit) + ? Math.min(Math.max(requestedLimit, 1), MAX_SESSION_PAGE_LIMIT) + : DEFAULT_SESSION_PAGE_LIMIT; + const requestedOffset = request?.offset; + const offset = typeof requestedOffset === "number" && Number.isInteger(requestedOffset) + ? Math.max(requestedOffset, 0) + : 0; + const status = + typeof request?.status === "string" && request.status.length > 0 + ? request.status + : null; + const q = + typeof request?.q === "string" && request.q.trim().length > 0 + ? request.q.trim() + : null; + return { limit, offset, status, q }; +} + +function pageWhereClause(status: string | null, q: string | null): { + whereSql: string; + params: unknown[]; +} { + const where: string[] = []; + const params: unknown[] = []; + if (status === "waiting") { + where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NOT NULL"); + } else if (status === "running") { + where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NULL"); + } else if (status && status !== "all") { + params.push(status); + where.push(`s.status = $${params.length}`); + } + if (q) { + const escaped = q.replace(/[%_]/g, (ch) => `\\${ch}`); + const like = `%${escaped}%`; + const start = params.length + 1; + params.push(like, like, like, like); + where.push( + `(s.id LIKE $${start} ESCAPE '\\' OR s.name LIKE $${start + 1} ESCAPE '\\' OR s.cwd LIKE $${start + 2} ESCAPE '\\' OR s.model LIKE $${start + 3} ESCAPE '\\')`, + ); + } + return { + whereSql: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "", + params, + }; +} + +function createPgliteAgentStore( + db: PgliteClient, + eventStore: ReturnType, +) { + return { + async getBySession(sessionId: string): Promise { + const result = await db.query( + "SELECT * FROM agents WHERE session_id = $1 ORDER BY started_at ASC", + [sessionId], + ); + return result.rows.map(toAgentRow).filter(Boolean) as AgentRow[]; + }, + async getBySessionWithChildren(sessionId: string): Promise { + const result = await db.query( + `SELECT a.*, + (SELECT COUNT(*)::int FROM agents child WHERE child.parent_agent_id = a.id) as children_count + FROM agents a WHERE a.session_id = $1 ORDER BY a.started_at ASC`, + [sessionId], + ); + const allAgents = result.rows.map(toAgentRow).filter(Boolean) as AgentRow[]; + const eventsByAgent = new Map(); + for (const e of await eventStore.getBySession(sessionId)) { + if (!e.agentId) continue; + const list = eventsByAgent.get(e.agentId) ?? []; + list.push({ + eventType: e.eventType, + toolName: e.toolName, + summary: e.summary, + createdAt: e.createdAt, + }); + eventsByAgent.set(e.agentId, list); + } + + const agentMap = new Map(); + const roots: AgentHierarchyNode[] = []; + for (const agent of allAgents) { + agentMap.set(agent.id, { + agentId: agent.id, + name: agent.name, + type: agent.type, + subagentType: agent.subagentType, + status: agent.status, + task: agent.task, + currentTool: agent.currentTool, + children: [], + events: eventsByAgent.get(agent.id) ?? [], + }); + } + for (const agent of allAgents) { + const node = agentMap.get(agent.id)!; + if (agent.parentAgentId && agentMap.has(agent.parentAgentId)) { + agentMap.get(agent.parentAgentId)!.children.push(node); + } else { + roots.push(node); + } + } + return roots; + }, + }; +} + +function createPgliteEventStore(db: PgliteClient) { + return { + async getBySession(sessionId: string): Promise { + const result = await db.query( + "SELECT * FROM events WHERE session_id = $1 ORDER BY created_at ASC", + [sessionId], + ); + return result.rows.map(toEventRow).filter(Boolean) as EventRow[]; + }, + async getBySessionAndAgent(sessionId: string, agentId: string): Promise { + const result = await db.query( + "SELECT * FROM events WHERE session_id = $1 AND agent_id = $2 ORDER BY created_at ASC", + [sessionId, agentId], + ); + return result.rows.map(toEventRow).filter(Boolean) as EventRow[]; + }, + async getAll(): Promise { + const result = await db.query( + "SELECT e.*, s.name as session_name FROM events e LEFT JOIN sessions s ON s.id = e.session_id ORDER BY e.created_at DESC LIMIT 200", + ); + return result.rows.map(toEventWithSession).filter(Boolean) as EventWithSession[]; + }, + async getWithSession(sessionId: string): Promise { + const result = await db.query( + "SELECT e.*, s.name as session_name FROM events e LEFT JOIN sessions s ON s.id = e.session_id WHERE e.session_id = $1 ORDER BY e.created_at ASC", + [sessionId], + ); + return result.rows.map(toEventWithSession).filter(Boolean) as EventWithSession[]; + }, + async getCountByType(): Promise { + const result = await db.query( + "SELECT event_type as event_type, COUNT(*)::int as count FROM events GROUP BY event_type ORDER BY count DESC", + ); + return result.rows.map((row) => ({ + eventType: row.event_type as string, + count: Number(row.count ?? 0), + })); + }, + }; +} + +function createPgliteTokenUsageStore(db: PgliteClient) { + return { + async replace( + sessionId: string, + model: string, + counts: TokenUsageCounts, + now: string, + tx: PgliteExecutor = db, + ): Promise { + if ( + counts.input === 0 && + counts.output === 0 && + counts.cacheRead === 0 && + counts.cacheWrite === 0 + ) { + return; + } + await tx.query( + ` + INSERT INTO token_usage ( + session_id, model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + raw_input, raw_output, raw_cache_read, raw_cache_write, + created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $3, $4, $5, $6, $7, $7) + ON CONFLICT (session_id, model) DO UPDATE SET + input_tokens = token_usage.input_tokens + (CASE WHEN EXCLUDED.raw_input < token_usage.raw_input + THEN EXCLUDED.raw_input ELSE EXCLUDED.raw_input - token_usage.raw_input END), + output_tokens = token_usage.output_tokens + (CASE WHEN EXCLUDED.raw_output < token_usage.raw_output + THEN EXCLUDED.raw_output ELSE EXCLUDED.raw_output - token_usage.raw_output END), + cache_read_tokens = token_usage.cache_read_tokens + (CASE WHEN EXCLUDED.raw_cache_read < token_usage.raw_cache_read + THEN EXCLUDED.raw_cache_read ELSE EXCLUDED.raw_cache_read - token_usage.raw_cache_read END), + cache_write_tokens = token_usage.cache_write_tokens + (CASE WHEN EXCLUDED.raw_cache_write < token_usage.raw_cache_write + THEN EXCLUDED.raw_cache_write ELSE EXCLUDED.raw_cache_write - token_usage.raw_cache_write END), + raw_input = EXCLUDED.raw_input, + raw_output = EXCLUDED.raw_output, + raw_cache_read = EXCLUDED.raw_cache_read, + raw_cache_write = EXCLUDED.raw_cache_write, + updated_at = EXCLUDED.updated_at + `, + [ + sessionId, + model, + counts.input, + counts.output, + counts.cacheRead, + counts.cacheWrite, + now, + ], + ); + }, + async getBySession(sessionId: string): Promise { + const result = await db.query( + `SELECT session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens + FROM token_usage WHERE session_id = $1 ORDER BY model ASC`, + [sessionId], + ); + return result.rows.map(toTokenUsageRow); + }, + }; +} + +function createPgliteDashboardQueries(db: PgliteClient) { + return { + async getSummary(): Promise { + const [ + totalSessions, + activeSessions, + totalAgents, + totalEvents, + eventTypeCount, + totalTokens, + recentSessions, + ] = await Promise.all([ + count(db, "SELECT COUNT(*)::int as count FROM sessions"), + count(db, `SELECT COUNT(*)::int as count FROM sessions WHERE status NOT IN ${TERMINAL_STATUSES}`), + count(db, "SELECT COUNT(*)::int as count FROM agents"), + count(db, "SELECT COUNT(*)::int as count FROM events"), + count(db, "SELECT COUNT(DISTINCT event_type)::int as count FROM events"), + scalarNumber(db, "SELECT COALESCE(SUM(input_tokens + output_tokens), 0)::int as total FROM token_usage", "total"), + db.query<{ + id: string; + name: string | null; + status: string; + model: string | null; + cwd: string | null; + started_at: string | null; + }>("SELECT id, name, status, model, cwd, started_at FROM sessions ORDER BY started_at DESC LIMIT 10"), + ]); + return { + totalSessions, + activeSessions, + totalAgents, + totalEvents, + eventTypeCount, + totalTokens, + recentSessions: recentSessions.rows.map((s) => ({ + id: s.id, + name: s.name, + status: s.status, + model: s.model, + cwd: s.cwd, + startedAt: s.started_at, + })), + }; + }, + async getTokenAnalytics(): Promise { + const totals = await db.query<{ + total_input: number; + total_output: number; + total_cache_read: number; + total_cache_write: number; + }>(` + SELECT COALESCE(SUM(input_tokens), 0)::int as total_input, + COALESCE(SUM(output_tokens), 0)::int as total_output, + COALESCE(SUM(cache_read_tokens), 0)::int as total_cache_read, + COALESCE(SUM(cache_write_tokens), 0)::int as total_cache_write + FROM token_usage + `); + const byModel = await db.query<{ + model: string; + input_tokens: number; + output_tokens: number; + sessions: number; + }>(` + SELECT model, + SUM(input_tokens)::int as input_tokens, + SUM(output_tokens)::int as output_tokens, + COUNT(DISTINCT session_id)::int as sessions + FROM token_usage + WHERE model IS NOT NULL + GROUP BY model + ORDER BY SUM(input_tokens + output_tokens) DESC + `); + const byDay = await db.query<{ + day: string; + input_tokens: number; + output_tokens: number; + }>(` + SELECT (created_at::timestamp::date)::text as day, + SUM(input_tokens)::int as input_tokens, + SUM(output_tokens)::int as output_tokens + FROM token_usage + WHERE created_at IS NOT NULL + GROUP BY created_at::timestamp::date + ORDER BY day DESC + LIMIT 30 + `); + const row = totals.rows[0]; + return { + totalInputTokens: Number(row?.total_input ?? 0), + totalOutputTokens: Number(row?.total_output ?? 0), + totalCacheReadTokens: Number(row?.total_cache_read ?? 0), + totalCacheWriteTokens: Number(row?.total_cache_write ?? 0), + byModel: byModel.rows.map((r) => ({ + model: r.model, + inputTokens: Number(r.input_tokens ?? 0), + outputTokens: Number(r.output_tokens ?? 0), + sessions: Number(r.sessions ?? 0), + })), + byDay: byDay.rows.map((r) => ({ + day: r.day, + inputTokens: Number(r.input_tokens ?? 0), + outputTokens: Number(r.output_tokens ?? 0), + })), + }; + }, + async getAnalytics(): Promise { + const [ + tokens, + eventsByType, + toolUsage, + dailyEvents, + sessionsByStatus, + agentsByStatus, + agentsByType, + totalSessions, + totalAgents, + totalEvents, + ] = await Promise.all([ + this.getTokenAnalytics(), + db.query<{ event_type: string; count: number }>("SELECT event_type, COUNT(*)::int as count FROM events GROUP BY event_type ORDER BY count DESC"), + db.query<{ tool_name: string; count: number }>("SELECT tool_name, COUNT(*)::int as count FROM events WHERE created_at::timestamp > now() - interval '30 days' AND tool_name IS NOT NULL GROUP BY tool_name ORDER BY count DESC LIMIT 20"), + db.query<{ date: string; count: number }>("SELECT (created_at::timestamp::date)::text as date, COUNT(*)::int as count FROM events WHERE created_at::timestamp > now() - interval '365 days' GROUP BY created_at::timestamp::date ORDER BY date ASC"), + db.query<{ status: string; count: number }>("SELECT status, COUNT(*)::int as count FROM sessions GROUP BY status"), + db.query<{ status: string; count: number }>("SELECT status, COUNT(*)::int as count FROM agents GROUP BY status"), + db.query<{ type: string; count: number }>("SELECT COALESCE(type, 'unknown') as type, COUNT(*)::int as count FROM agents GROUP BY type ORDER BY count DESC"), + count(db, "SELECT COUNT(*)::int as count FROM sessions"), + count(db, "SELECT COUNT(*)::int as count FROM agents"), + count(db, "SELECT COUNT(*)::int as count FROM events"), + ]); + return { + tokens, + eventsByType: eventsByType.rows.map((r) => ({ eventType: r.event_type, count: Number(r.count ?? 0) })), + toolUsage: toolUsage.rows.map((r) => ({ toolName: r.tool_name, count: Number(r.count ?? 0) })), + dailyEvents: dailyEvents.rows.map((r) => ({ date: r.date, count: Number(r.count ?? 0) })), + sessionsByStatus: sessionsByStatus.rows.map((r) => ({ status: r.status, count: Number(r.count ?? 0) })), + agentsByStatus: agentsByStatus.rows.map((r) => ({ status: r.status, count: Number(r.count ?? 0) })), + agentsByType: agentsByType.rows.map((r) => ({ type: r.type, count: Number(r.count ?? 0) })), + totalSessions, + totalAgents, + totalEvents, + }; + }, + async getWorkflowData(): Promise { + const totalSessions = await count(db, "SELECT COUNT(*)::int as count FROM sessions"); + const totalAgents = await count(db, "SELECT COUNT(*)::int as count FROM agents"); + const totalSubagents = await count(db, "SELECT COUNT(*)::int as count FROM agents WHERE type = 'subagent' OR parent_agent_id IS NOT NULL"); + const completedAgents = await count(db, "SELECT COUNT(*)::int as count FROM agents WHERE status = 'completed'"); + const errorAgents = await count(db, "SELECT COUNT(*)::int as count FROM agents WHERE status = 'failed' OR status = 'error'"); + const depthRows = await db.query<{ session_id: string; max_depth: number }>(` + WITH RECURSIVE agent_depth(id, session_id, depth) AS ( + SELECT id, session_id, 0 FROM agents WHERE parent_agent_id IS NULL + UNION ALL + SELECT a.id, a.session_id, ad.depth + 1 + FROM agents a JOIN agent_depth ad ON a.parent_agent_id = ad.id + ) + SELECT session_id, MAX(depth)::int as max_depth FROM agent_depth GROUP BY session_id + `); + const durationRow = await db.query<{ avg: number | null }>(` + SELECT AVG(EXTRACT(EPOCH FROM ((COALESCE(ended_at, updated_at))::timestamp - started_at::timestamp))) as avg + FROM sessions WHERE started_at IS NOT NULL + `); + const subagentTypes = await db.query<{ + subagent_type: string; + count: number; + completed: number; + errors: number; + }>(` + SELECT COALESCE(subagent_type, COALESCE(name, 'unknown')) as subagent_type, + COUNT(*)::int as count, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)::int as completed, + SUM(CASE WHEN status IN ('failed', 'error') THEN 1 ELSE 0 END)::int as errors + FROM agents WHERE parent_agent_id IS NOT NULL OR type = 'subagent' + GROUP BY subagent_type ORDER BY count DESC + `); + const mainCount = await count(db, "SELECT COUNT(*)::int as count FROM agents WHERE parent_agent_id IS NULL AND (type IS NULL OR type != 'subagent')"); + const edges = await db.query<{ source: string; target: string; weight: number }>(` + SELECT COALESCE(p.subagent_type, COALESCE(p.name, 'main')) as source, + COALESCE(c.subagent_type, COALESCE(c.name, 'unknown')) as target, + COUNT(*)::int as weight + FROM agents c JOIN agents p ON c.parent_agent_id = p.id + GROUP BY source, target ORDER BY weight DESC LIMIT 50 + `); + const outcomes = await db.query<{ status: string; count: number }>("SELECT status, COUNT(*)::int as count FROM sessions GROUP BY status"); + const toolTransitions = await db.query<{ source: string; target: string; value: number }>(` + WITH recent_tools AS ( + SELECT tool_name, session_id, created_at, id + FROM events + WHERE tool_name IS NOT NULL + AND created_at::timestamp > now() - interval '7 days' + ), + tool_seq AS ( + SELECT tool_name, + LEAD(tool_name) OVER (PARTITION BY session_id ORDER BY created_at, id) as next_tool + FROM recent_tools + ) + SELECT tool_name as source, next_tool as target, COUNT(*)::int as value + FROM tool_seq + WHERE next_tool IS NOT NULL + GROUP BY source, target ORDER BY value DESC LIMIT 30 + `); + const toolCounts = await db.query<{ tool_name: string; count: number }>("SELECT tool_name, COUNT(*)::int as count FROM events WHERE created_at::timestamp > now() - interval '30 days' AND tool_name IS NOT NULL GROUP BY tool_name ORDER BY count DESC LIMIT 20"); + const cooccurrence = await db.query<{ source: string; target: string; weight: number }>(` + SELECT COALESCE(a1.subagent_type, COALESCE(a1.name, 'unknown')) as source, + COALESCE(a2.subagent_type, COALESCE(a2.name, 'unknown')) as target, + COUNT(DISTINCT a1.session_id)::int as weight + FROM agents a1 JOIN agents a2 ON a1.session_id = a2.session_id AND a1.id < a2.id + GROUP BY source, target ORDER BY weight DESC LIMIT 30 + `); + const avgDepth = depthRows.rows.length > 0 + ? depthRows.rows.reduce((sum, row) => sum + Number(row.max_depth ?? 0), 0) / depthRows.rows.length + : 0; + const successRate = completedAgents + errorAgents > 0 + ? completedAgents / (completedAgents + errorAgents) * 100 + : 100; + const mappedSubagentTypes = subagentTypes.rows.map((row) => ({ + subagentType: row.subagent_type, + count: Number(row.count ?? 0), + completed: Number(row.completed ?? 0), + errors: Number(row.errors ?? 0), + })); + return { + stats: { + totalSessions, + totalAgents, + totalSubagents, + avgSubagents: totalSessions > 0 ? totalSubagents / totalSessions : 0, + successRate, + avgDepth, + avgDurationSec: Number(durationRow.rows[0]?.avg ?? 0), + totalCompactions: 0, + avgCompactions: 0, + topFlow: toolTransitions.rows.length > 0 + ? { + source: toolTransitions.rows[0].source, + target: toolTransitions.rows[0].target, + count: Number(toolTransitions.rows[0].value ?? 0), + } + : null, + }, + orchestration: { + sessionCount: totalSessions, + mainCount, + subagentTypes: mappedSubagentTypes, + edges: edges.rows.map((r) => ({ source: r.source, target: r.target, weight: Number(r.weight ?? 0) })), + outcomes: outcomes.rows.map((r) => ({ status: r.status, count: Number(r.count ?? 0) })), + compactions: { total: 0, sessions: 0 }, + }, + toolFlow: { + transitions: toolTransitions.rows.map((r) => ({ source: r.source, target: r.target, value: Number(r.value ?? 0) })), + toolCounts: toolCounts.rows.map((r) => ({ toolName: r.tool_name, count: Number(r.count ?? 0) })), + }, + effectiveness: mappedSubagentTypes.map((st) => ({ + subagentType: st.subagentType, + total: st.count, + completed: st.completed, + errors: st.errors, + sessions: 0, + successRate: st.count > 0 ? st.completed / st.count * 100 : 0, + avgDuration: null, + trend: [], + })), + cooccurrence: cooccurrence.rows.map((r) => ({ source: r.source, target: r.target, weight: Number(r.weight ?? 0) })), + }; + }, + }; +} + +function createPgliteLifecycle( + db: PgliteClient, + queue: ReturnType, + tokenUsage: ReturnType, + deps: { + detectBillingMode: (harness: string) => string; + emit?: (sessionId: string) => void; + extractTranscript?: (path: string) => TranscriptExtract | null; + log: (message: string) => void; + now: () => string; + staleMinutes?: number; + }, +) { + const staleMinutes = deps.staleMinutes ?? 180; + const extract = deps.extractTranscript ?? extractTranscriptTokens; + + return { + async processEvent(hookType: string, data: HookData, harness: string): Promise { + const sessionId = data.session_id; + if (typeof sessionId !== "string" || sessionId.length === 0) { + return false; + } + let transcript: TranscriptExtract | null = null; + if (data.transcript_path) { + try { + transcript = extract(data.transcript_path); + } catch { + transcript = null; + } + } + const now = deps.now(); + const processed = await queue.run(async () => { + try { + await db.transaction(async (tx) => { + await handleHook(tx, { + data, + hookType, + harness, + now, + sessionId, + staleMinutes, + tokenUsage, + transcript, + detectBillingMode: deps.detectBillingMode, + }); + }); + return true; + } catch (error) { + deps.log( + `pglite lifecycle: failed to process ${hookType}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + }); + if (processed) { + try { + deps.emit?.(sessionId); + } catch { + /* live-update push is best-effort */ + } + } + return processed; + }, + }; +} + +async function handleHook( + tx: PgliteExecutor, + options: { + data: HookData; + hookType: string; + harness: string; + now: string; + sessionId: string; + staleMinutes: number; + tokenUsage: ReturnType; + transcript: TranscriptExtract | null; + detectBillingMode: (harness: string) => string; + }, +): Promise { + const { data, hookType, harness, now, sessionId } = options; + const main = mainAgentId(sessionId); + await ensureSession(tx, sessionId, data, harness, now, options.detectBillingMode); + const session = await getSession(tx, sessionId); + if (!session) { + return; + } + await maybeReactivate(tx, session, hookType, now); + await tx.query("UPDATE sessions SET updated_at = $1 WHERE id = $2", [now, sessionId]); + + switch (hookType) { + case "SessionStart": + await setMainWaiting(tx, sessionId, now); + await sweepStaleSessions(tx, sessionId, now, options.staleMinutes); + await insertEvent(tx, sessionId, main, "SessionStart", data, now, data.source === "resume" ? "Resumed session" : "Started session"); + break; + case "UserPromptSubmit": + await clearAwaitingInput(tx, sessionId, now); + await promoteMain(tx, main, now); + await insertEvent(tx, sessionId, main, "UserPromptSubmit", data, now); + break; + case "PreToolUse": + await clearAwaitingInput(tx, sessionId, now); + if (data.tool_name === "Agent" || data.tool_name === "Task") { + const agentId = await spawnSubagent(tx, sessionId, data, now); + await insertEvent(tx, sessionId, agentId, "PreToolUse", data, now, "Spawned subagent"); + } else { + await setAgentTool(tx, main, data.tool_name ?? null, now); + await insertEvent(tx, sessionId, main, "PreToolUse", data, now); + } + break; + case "PostToolUse": { + await clearAwaitingInput(tx, sessionId, now); + const mainAgent = await getAgent(tx, main); + if (mainAgent && mainAgent.status === "working") { + await setAgentTool(tx, main, null, now); + } + await insertEvent(tx, sessionId, main, "PostToolUse", data, now); + break; + } + case "Stop": + if (data.stop_reason === "error") { + await setAgentStatus(tx, main, "error", now); + await setSessionStatus(tx, sessionId, "error", now); + await clearAwaitingInput(tx, sessionId, now); + } else { + await setMainWaiting(tx, sessionId, now); + } + await insertEvent(tx, sessionId, main, "Stop", data, now); + break; + case "SubagentStop": { + const agentId = await matchSubagent(tx, sessionId, data); + if (agentId) { + await setAgentStatus(tx, agentId, "completed", now); + } + await insertEvent(tx, sessionId, agentId, "SubagentStop", data, now); + break; + } + case "Notification": { + const message = strOf(data.message) ?? ""; + if (COMPACTION_RE.test(message)) { + await insertEvent(tx, sessionId, main, "Compaction", data, now, "Context compaction"); + } else if (WAITING_INPUT_RE.test(message)) { + await setMainWaiting(tx, sessionId, now); + await insertEvent(tx, sessionId, main, "Notification", data, now, message.slice(0, 200)); + } else { + await insertEvent(tx, sessionId, main, "Notification", data, now, message.slice(0, 200) || undefined); + } + break; + } + case "SessionEnd": { + await clearAwaitingInput(tx, sessionId, now); + const finalStatus = session.status === "error" ? "error" : "completed"; + await tx.query( + "UPDATE agents SET status = $1, ended_at = $2, updated_at = $2 WHERE session_id = $3 AND status NOT IN ('completed', 'error')", + [finalStatus === "error" ? "error" : "completed", now, sessionId], + ); + await setSessionStatus(tx, sessionId, finalStatus, now); + await insertEvent(tx, sessionId, main, "SessionEnd", data, now); + break; + } + default: + await insertEvent(tx, sessionId, main, hookType, data, now); + break; + } + + if (options.transcript) { + if (options.transcript.latestModel) { + await tx.query( + "UPDATE sessions SET model = $1, updated_at = $2 WHERE id = $3 AND COALESCE(model, '') != $1", + [options.transcript.latestModel, now, sessionId], + ); + } + for (const [model, counts] of options.transcript.tokensByModel) { + await options.tokenUsage.replace(sessionId, model, counts, now, tx); + } + } +} + +function createPgliteImporter( + db: PgliteClient, + queue: ReturnType, + tokenUsage: ReturnType, + deps: { + detectBillingMode: (harness: string) => string; + now: () => string; + log: (message: string) => void; + }, +): Importer { + return { + async importSession(session: NormalizedSession, harness: Harness): Promise { + if (typeof session.sessionId !== "string" || session.sessionId.length === 0 || !session.startedAt) { + return { skipped: true, reactivated: false }; + } + return queue.run(async () => { + const now = deps.now(); + try { + return await db.transaction(async (tx) => importSessionWithTx(tx, tokenUsage, deps, session, harness, now)); + } catch (error) { + deps.log( + `pglite importSession failed for ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + return { skipped: true, reactivated: false }; + } + }); + }, + }; +} + +async function importSessionWithTx( + tx: PgliteExecutor, + tokenUsage: ReturnType, + deps: { + detectBillingMode: (harness: string) => string; + }, + session: NormalizedSession, + harness: Harness, + now: string, +): Promise { + const nowMs = Date.parse(now); + const recentlyActive = + session.fileModifiedAt != null && + Number.isFinite(session.fileModifiedAt) && + (Number.isNaN(nowMs) ? Date.now() : nowMs) - session.fileModifiedAt < RECENT_ACTIVITY_MS; + const mainId = mainAgentId(session.sessionId); + const existing = await getImportSession(tx, session.sessionId); + let reactivated = false; + + if (!existing) { + const status = recentlyActive ? "active" : "completed"; + const billingMode = safe(() => deps.detectBillingMode(harness)) ?? "unknown"; + await tx.query( + `INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, ended_at, harness, billing_mode, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + session.sessionId, + session.name ?? null, + status, + session.cwd ?? null, + session.model ?? null, + session.startedAt, + session.endedAt ?? session.startedAt, + status === "completed" ? session.endedAt ?? null : null, + harness, + billingMode, + buildImportMetadata(session, harness), + ], + ); + await tx.query( + `INSERT INTO agents (id, session_id, name, type, subagent_type, status, task, current_tool, started_at, updated_at, ended_at, parent_agent_id, metadata) + VALUES ($1, $2, 'main', 'main', NULL, $3, NULL, NULL, $4, $5, $6, NULL, NULL)`, + [ + mainId, + session.sessionId, + status === "completed" ? "completed" : "waiting", + session.startedAt, + now, + status === "completed" ? session.endedAt ?? now : null, + ], + ); + } else { + const billingMode = safe(() => deps.detectBillingMode(harness)) ?? "unknown"; + await tx.query( + `UPDATE sessions SET + name = COALESCE(name, $1), + model = COALESCE(model, $2), + cwd = COALESCE(cwd, $3), + harness = CASE WHEN COALESCE(harness, '') = '' THEN $4 ELSE harness END, + billing_mode = CASE WHEN COALESCE(billing_mode, '') IN ('', 'unknown') THEN $5 ELSE billing_mode END, + updated_at = $6 + WHERE id = $7`, + [ + session.name ?? null, + session.model ?? null, + session.cwd ?? null, + harness, + billingMode, + now, + session.sessionId, + ], + ); + const isLive = existing.status === "active" && existing.ended_at == null; + if (recentlyActive && !isLive) { + await tx.query("UPDATE sessions SET status = 'active', ended_at = NULL, updated_at = $1 WHERE id = $2", [now, session.sessionId]); + await tx.query( + "UPDATE agents SET status = 'waiting', ended_at = NULL, current_tool = NULL, awaiting_input_since = NULL, updated_at = $1 WHERE id = $2", + [now, mainId], + ); + reactivated = true; + } + } + + const highWater = new Map(); + const hwm = await tx.query<{ event_type: string; hwm: string | null }>( + "SELECT event_type, MAX(created_at) AS hwm FROM events WHERE session_id = $1 GROUP BY event_type", + [session.sessionId], + ); + for (const row of hwm.rows) { + if (row.hwm) highWater.set(row.event_type, row.hwm); + } + + let inserted = 0; + const addEvent = async ( + eventType: string, + agentId: string, + ts: string | null, + toolName: string | null, + summary: string | null, + data: string | null, + ): Promise => { + if (!ts) return; + const prev = highWater.get(eventType); + if (prev != null && ts <= prev) return; + await tx.query( + "INSERT INTO events (id, session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [randomUUID(), session.sessionId, agentId, eventType, toolName, summary, data, ts], + ); + inserted++; + }; + + for (const ts of session.messageTimestamps ?? []) { + await addEvent("Stop", mainId, ts, null, null, null); + } + for (const [idx, tu] of (session.toolUses ?? []).entries()) { + if (tu.name === "Agent" || tu.name === "Task") { + const subId = `${session.sessionId}-sub-${idx}`; + const input = (tu.input ?? {}) as Record; + const prompt = strOf(input.prompt); + await tx.query( + `INSERT INTO agents (id, session_id, name, type, subagent_type, status, task, started_at, updated_at, ended_at, parent_agent_id) + VALUES ($1, $2, $3, 'subagent', $4, 'completed', $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING`, + [ + subId, + session.sessionId, + subagentName(tu), + strOf(input.subagent_type) ?? null, + prompt ? prompt.slice(0, 500) : null, + tu.timestamp ?? session.startedAt, + now, + tu.timestamp ?? session.endedAt ?? now, + mainId, + ], + ); + await addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", importEventData(tu.input)); + } else { + await addEvent("PostToolUse", mainId, tu.timestamp, tu.name, null, importEventData(tu.input)); + } + } + for (const td of session.turnDurations ?? []) { + await addEvent("TurnDuration", mainId, td.timestamp, null, String(td.durationMs), null); + } + for (const err of session.apiErrors ?? []) { + await addEvent("APIError", mainId, err.timestamp, null, err.message ?? err.type ?? null, null); + } + for (const err of session.toolResultErrors ?? []) { + await addEvent("ToolError", mainId, err.timestamp, null, truncate(err.content, 200), null); + } + for (const [model, counts] of Object.entries(session.tokensByModel ?? {})) { + await tokenUsage.replace(session.sessionId, model, counts, now, tx); + } + + return { skipped: existing != null && inserted === 0 && !reactivated, reactivated }; +} + +function createPgliteSessionSyncSource(db: PgliteClient): AgentSessionSyncSource { + return { + async listAllSessionCursorRows(): Promise { + const result = await db.query(` + SELECT id, updated_at + FROM sessions + ORDER BY updated_at DESC, id DESC + `); + return result.rows; + }, + async listUpdatedSessionCursorRows( + sinceUpdatedAt: string, + ): Promise { + const result = await db.query( + ` + SELECT id, updated_at + FROM sessions + WHERE updated_at >= $1 + ORDER BY updated_at DESC, id DESC + `, + [sinceUpdatedAt], + ); + return result.rows; + }, + async loadSyncedSessions( + ids: string[], + cache: SessionAttributionResolverCache, + ): Promise { + return loadPgliteSyncedSessions(db, ids, cache); + }, + }; +} + +async function loadPgliteSyncedSessions( + db: PgliteClient, + ids: string[], + cache: SessionAttributionResolverCache, +): Promise { + if (ids.length === 0) { + return []; + } + + const sessionRows = await selectRowsByIds<{ + id: string; + name: string | null; + status: string; + cwd: string | null; + model: string | null; + started_at: string; + updated_at: string; + ended_at: string | null; + awaiting_input_since: string | null; + metadata: string | null; + harness: string | null; + billing_mode: string | null; + }>( + db, + ` + SELECT + id, + name, + status, + cwd, + model, + started_at, + updated_at, + ended_at, + awaiting_input_since, + metadata, + harness, + billing_mode + FROM sessions + WHERE id IN (__IDS__) + `, + ids, + ); + const agentRows = await selectRowsByIds<{ + id: string; + session_id: string; + name: string; + type: string; + subagent_type: string | null; + status: string; + task: string | null; + current_tool: string | null; + started_at: string; + updated_at: string; + ended_at: string | null; + awaiting_input_since: string | null; + parent_agent_id: string | null; + metadata: string | null; + }>( + db, + ` + SELECT + id, + session_id, + name, + type, + subagent_type, + status, + task, + current_tool, + started_at, + updated_at, + ended_at, + awaiting_input_since, + parent_agent_id, + metadata + FROM agents + WHERE session_id IN (__IDS__) + ORDER BY session_id ASC, started_at ASC, id ASC + `, + ids, + ); + const eventRows = await selectRowsByIds<{ + id: string; + session_id: string; + agent_id: string | null; + event_type: string; + tool_name: string | null; + summary: string | null; + data: string | null; + created_at: string; + }>( + db, + ` + SELECT + id, + session_id, + agent_id, + event_type, + tool_name, + summary, + data, + created_at + FROM events + WHERE session_id IN (__IDS__) + ORDER BY session_id ASC, created_at ASC, id ASC + `, + ids, + ); + const tokenRows = await selectRowsByIds<{ + session_id: string; + model: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + }>( + db, + ` + SELECT + session_id, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens + FROM token_usage + WHERE session_id IN (__IDS__) + ORDER BY session_id ASC, model ASC + `, + ids, + ); + + const sessionsById = new Map(sessionRows.map((row) => [row.id, row])); + const agentsBySessionId = groupRowsBySessionId(agentRows); + const eventsBySessionId = groupRowsBySessionId(eventRows); + const tokenUsageBySessionId = groupRowsBySessionId(tokenRows); + + return ids.flatMap((id) => { + const row = sessionsById.get(id); + if (!row) { + return []; + } + const attribution = resolveSessionAttribution(row.cwd, cache); + const tokenUsageByModel: SyncedAgentSessionTokenUsage[] = ( + tokenUsageBySessionId.get(id) ?? [] + ).map((tokenRow) => { + const estimatedCostUsd = estimateTokenUsageCostUsd(tokenRow); + return { + model: tokenRow.model, + inputTokens: Number(tokenRow.input_tokens ?? 0), + outputTokens: Number(tokenRow.output_tokens ?? 0), + cacheReadTokens: Number(tokenRow.cache_read_tokens ?? 0), + cacheWriteTokens: Number(tokenRow.cache_write_tokens ?? 0), + ...(estimatedCostUsd !== undefined ? { estimatedCostUsd } : {}), + }; + }); + + return [ + { + externalSessionId: row.id, + name: row.name, + status: row.status, + harness: row.harness, + billingMode: resolveBillingModeForRow(row), + cwd: row.cwd, + model: row.model, + startedAt: row.started_at, + updatedAt: row.updated_at, + endedAt: row.ended_at, + awaitingInputSince: row.awaiting_input_since, + metadata: parseJsonObjectText(row.metadata), + ...(attribution ? { attribution } : {}), + agents: (agentsBySessionId.get(id) ?? []).map((agentRow) => ({ + externalAgentId: agentRow.id, + name: agentRow.name, + type: agentRow.type, + subagentType: agentRow.subagent_type, + status: agentRow.status, + task: agentRow.task, + currentTool: agentRow.current_tool, + startedAt: agentRow.started_at, + updatedAt: agentRow.updated_at, + endedAt: agentRow.ended_at, + awaitingInputSince: agentRow.awaiting_input_since, + parentExternalAgentId: agentRow.parent_agent_id, + metadata: parseJsonObjectText(agentRow.metadata), + })), + events: (eventsBySessionId.get(id) ?? []).map((eventRow) => ({ + externalEventId: String(eventRow.id), + agentExternalId: eventRow.agent_id, + eventType: eventRow.event_type, + toolName: eventRow.tool_name, + summary: eventRow.summary, + data: parseJsonValueText(eventRow.data), + createdAt: eventRow.created_at, + })), + tokenUsageByModel, + }, + ]; + }); +} + +async function selectRowsByIds>( + db: PgliteExecutor, + sql: string, + ids: string[], +): Promise { + const placeholders = ids.map((_, index) => `$${index + 1}`).join(", "); + const result = await db.query(sql.replace("__IDS__", placeholders), ids); + return result.rows; +} + +function groupRowsBySessionId< + T extends { session_id: string }, +>(rows: T[]): Map { + const grouped = new Map(); + for (const row of rows) { + const existing = grouped.get(row.session_id); + if (existing) { + existing.push(row); + } else { + grouped.set(row.session_id, [row]); + } + } + return grouped; +} + +async function loadPgliteMeteredUsageRows( + db: PgliteClient, + cutoffIso: string, +): Promise { + const result = await db.query<{ + session_id: string; + started_at: string; + billing_mode: string | null; + harness: string | null; + model: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + }>( + ` + SELECT + s.id AS session_id, + s.started_at AS started_at, + s.billing_mode AS billing_mode, + s.harness AS harness, + tu.model AS model, + tu.input_tokens AS input_tokens, + tu.output_tokens AS output_tokens, + tu.cache_read_tokens AS cache_read_tokens, + tu.cache_write_tokens AS cache_write_tokens + FROM token_usage tu + JOIN sessions s ON s.id = tu.session_id + WHERE s.started_at >= $1 + ORDER BY s.started_at ASC, tu.model ASC + `, + [cutoffIso], + ); + const out: MeteredUsageRow[] = []; + for (const row of result.rows) { + const billingMode = resolveBillingMode({ + billingMode: row.billing_mode, + harness: row.harness, + }); + if (!isMeteredApi(billingMode)) { + continue; + } + out.push({ + sessionId: row.session_id, + model: row.model, + startedAt: row.started_at, + billingMode, + inputTokens: Number(row.input_tokens ?? 0), + outputTokens: Number(row.output_tokens ?? 0), + cacheReadTokens: Number(row.cache_read_tokens ?? 0), + cacheWriteTokens: Number(row.cache_write_tokens ?? 0), + }); + } + return out; +} + +function sessionDetailsCtes(): string { + return ` + WITH agent_counts AS ( + SELECT session_id, COUNT(*)::int as agent_count + FROM agents + GROUP BY session_id + ), + event_counts AS ( + SELECT session_id, COUNT(*)::int as event_count + FROM events + GROUP BY session_id + ), + token_totals AS ( + SELECT + session_id, + COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0)::int as total_tokens + FROM token_usage + GROUP BY session_id + ) + `; +} + +function toSessionRow(raw: Record | undefined): SessionRow | undefined { + if (!raw) return undefined; + return { + id: raw.id as string, + name: (raw.name as string) ?? null, + status: raw.status as string, + cwd: (raw.cwd as string) ?? null, + model: (raw.model as string) ?? null, + startedAt: (raw.started_at as string) ?? null, + updatedAt: (raw.updated_at as string) ?? null, + endedAt: (raw.ended_at as string) ?? null, + awaitingInputSince: (raw.awaiting_input_since as string) ?? null, + metadata: (raw.metadata as string) ?? null, + harness: (raw.harness as string) ?? null, + billingMode: (raw.billing_mode as string) ?? null, + userId: (raw.user_id as string) ?? null, + organizationId: (raw.organization_id as string) ?? null, + }; +} + +function toAgentRow(raw: Record | undefined): AgentRow | undefined { + if (!raw) return undefined; + return { + id: raw.id as string, + sessionId: raw.session_id as string, + name: (raw.name as string) ?? null, + type: (raw.type as string) ?? null, + subagentType: (raw.subagent_type as string) ?? null, + status: raw.status as string, + task: (raw.task as string) ?? null, + currentTool: (raw.current_tool as string) ?? null, + startedAt: (raw.started_at as string) ?? null, + updatedAt: (raw.updated_at as string) ?? null, + endedAt: (raw.ended_at as string) ?? null, + awaitingInputSince: (raw.awaiting_input_since as string) ?? null, + parentAgentId: (raw.parent_agent_id as string) ?? null, + metadata: (raw.metadata as string) ?? null, + }; +} + +function toEventRow(raw: Record | undefined): EventRow | undefined { + if (!raw) return undefined; + return { + id: raw.id as string, + sessionId: raw.session_id as string, + agentId: (raw.agent_id as string) ?? null, + eventType: raw.event_type as string, + toolName: (raw.tool_name as string) ?? null, + summary: (raw.summary as string) ?? null, + data: (raw.data as string) ?? null, + createdAt: (raw.created_at as string) ?? null, + }; +} + +function toEventWithSession(raw: Record | undefined): EventWithSession | undefined { + const row = toEventRow(raw); + if (!row) return undefined; + return { ...row, sessionName: (raw?.session_name as string) ?? null }; +} + +function toTokenUsageRow(raw: Record): TokenUsageRow { + return { + sessionId: raw.session_id as string, + model: raw.model as string, + inputTokens: Number(raw.input_tokens ?? 0), + outputTokens: Number(raw.output_tokens ?? 0), + cacheReadTokens: Number(raw.cache_read_tokens ?? 0), + cacheWriteTokens: Number(raw.cache_write_tokens ?? 0), + }; +} + +function detailRowsToList(raws: Record[]): SessionWithAgents[] { + return raws.map((raw) => { + const base = toSessionRow(raw)!; + return { + ...base, + agentCount: Number(raw.agent_count ?? 0), + eventCount: Number(raw.event_count ?? 0), + totalTokens: Number(raw.total_tokens ?? 0), + }; + }); +} + +async function count(db: PgliteExecutor, sql: string, params?: unknown[]): Promise { + return scalarNumber(db, sql, "count", params); +} + +async function scalarNumber( + db: PgliteExecutor, + sql: string, + key: string, + params?: unknown[], +): Promise { + const result = await db.query(sql, params); + return Number(result.rows[0]?.[key] ?? 0); +} + +function mainAgentId(sessionId: string): string { + return `${sessionId}-main`; +} + +async function getSession(tx: PgliteExecutor, sessionId: string): Promise { + const result = await tx.query( + "SELECT id, status, harness, billing_mode, model FROM sessions WHERE id = $1", + [sessionId], + ); + return result.rows[0]; +} + +async function getImportSession(tx: PgliteExecutor, sessionId: string): Promise<{ id: string; status: string; ended_at: string | null } | undefined> { + const result = await tx.query<{ id: string; status: string; ended_at: string | null }>( + "SELECT id, status, ended_at FROM sessions WHERE id = $1", + [sessionId], + ); + return result.rows[0]; +} + +async function getAgent(tx: PgliteExecutor, agentId: string): Promise { + const result = await tx.query( + "SELECT id, status, type, parent_agent_id FROM agents WHERE id = $1", + [agentId], + ); + return result.rows[0]; +} + +async function ensureSession( + tx: PgliteExecutor, + sessionId: string, + data: HookData, + harness: string, + now: string, + detectBillingMode: (harness: string) => string, +): Promise { + if (await getSession(tx, sessionId)) { + return; + } + const billingMode = safe(() => detectBillingMode(harness)) ?? "unknown"; + await tx.query( + `INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, harness, billing_mode) + VALUES ($1, $2, 'active', $3, $4, $5, $5, $6, $7)`, + [ + sessionId, + data.session_name ?? null, + data.cwd ?? null, + data.model ?? null, + now, + harness, + billingMode, + ], + ); + await tx.query( + `INSERT INTO agents (id, session_id, name, type, subagent_type, status, task, current_tool, started_at, updated_at, parent_agent_id, metadata) + VALUES ($1, $2, 'main', 'main', NULL, 'working', NULL, NULL, $3, $3, NULL, NULL)`, + [mainAgentId(sessionId), sessionId, now], + ); +} + +async function clearAwaitingInput(tx: PgliteExecutor, sessionId: string, now: string): Promise { + await tx.query("UPDATE sessions SET awaiting_input_since = NULL, updated_at = $1 WHERE id = $2", [now, sessionId]); + await tx.query("UPDATE agents SET awaiting_input_since = NULL, updated_at = $1 WHERE session_id = $2 AND awaiting_input_since IS NOT NULL", [now, sessionId]); +} + +async function setMainWaiting(tx: PgliteExecutor, sessionId: string, now: string): Promise { + await tx.query("UPDATE sessions SET awaiting_input_since = $1, updated_at = $1 WHERE id = $2", [now, sessionId]); + await tx.query("UPDATE agents SET awaiting_input_since = $1, status = 'waiting', updated_at = $1 WHERE id = $2", [now, mainAgentId(sessionId)]); +} + +async function promoteMain(tx: PgliteExecutor, main: string, now: string): Promise { + await tx.query("UPDATE agents SET status = 'working', awaiting_input_since = NULL, updated_at = $1 WHERE id = $2 AND status != 'working'", [now, main]); +} + +async function setAgentTool(tx: PgliteExecutor, agentId: string, toolName: string | null, now: string): Promise { + await tx.query("UPDATE agents SET current_tool = $1, status = 'working', updated_at = $2 WHERE id = $3", [toolName, now, agentId]); +} + +async function setAgentStatus(tx: PgliteExecutor, agentId: string, status: string, now: string): Promise { + await tx.query("UPDATE agents SET status = $1, updated_at = $2, ended_at = $2 WHERE id = $3", [status, now, agentId]); +} + +async function setSessionStatus(tx: PgliteExecutor, sessionId: string, status: string, now: string): Promise { + await tx.query("UPDATE sessions SET status = $1, updated_at = $2, ended_at = $2 WHERE id = $3", [status, now, sessionId]); +} + +async function insertEvent( + tx: PgliteExecutor, + sessionId: string, + agentId: string | null, + eventType: string, + data: HookData, + now: string, + summary?: string, +): Promise { + await tx.query( + "INSERT INTO events (id, session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + randomUUID(), + sessionId, + agentId, + eventType, + data.tool_name ?? null, + summary ?? null, + safe(() => JSON.stringify(data)) ?? null, + now, + ], + ); +} + +async function maybeReactivate( + tx: PgliteExecutor, + session: SessionRowRaw, + hookType: string, + now: string, +): Promise { + if (session.status === "active" || hookType === "SessionEnd") { + return; + } + const isUserActivity = hookType === "UserPromptSubmit" || hookType === "PreToolUse"; + const isStopLike = hookType === "Stop" || hookType === "SubagentStop"; + const reactivate = + isUserActivity || + (!isStopLike && session.status !== "error") || + (isStopLike && (session.status === "completed" || session.status === "abandoned")); + if (reactivate) { + await tx.query("UPDATE sessions SET status = 'active', updated_at = $1, ended_at = NULL WHERE id = $2", [now, session.id]); + await promoteMain(tx, mainAgentId(session.id), now); + session.status = "active"; + } +} + +async function spawnSubagent( + tx: PgliteExecutor, + sessionId: string, + data: HookData, + now: string, +): Promise { + const input = (data.tool_input as Record | undefined) ?? {}; + const description = strOf(input.description) ?? strOf(data.description); + const subagentType = strOf(input.subagent_type) ?? strOf(data.subagent_type); + const prompt = strOf(input.prompt) ?? strOf(data.prompt); + const name = + description ?? + subagentType ?? + (prompt ? prompt.split("\n")[0].slice(0, 60) : undefined) ?? + "Subagent"; + let parentId = mainAgentId(sessionId); + const main = await getAgent(tx, parentId); + if (!main || main.status !== "working") { + const deepest = await tx.query<{ id: string }>(` + WITH RECURSIVE chain(id, depth) AS ( + SELECT id, 0 FROM agents WHERE session_id = $1 AND parent_agent_id IS NULL + UNION ALL + SELECT a.id, c.depth + 1 FROM agents a JOIN chain c ON a.parent_agent_id = c.id + ) + SELECT a.id AS id FROM chain c JOIN agents a ON a.id = c.id + WHERE a.status = 'working' AND a.type = 'subagent' + ORDER BY c.depth DESC, a.started_at DESC LIMIT 1 + `, [sessionId]); + if (deepest.rows[0]) { + parentId = deepest.rows[0].id; + } + } + const agentId = `${sessionId}-sub-${randomUUID().slice(0, 8)}`; + await tx.query( + `INSERT INTO agents (id, session_id, name, type, subagent_type, status, task, current_tool, started_at, updated_at, parent_agent_id, metadata) + VALUES ($1, $2, $3, 'subagent', $4, 'working', $5, NULL, $6, $6, $7, NULL)`, + [ + agentId, + sessionId, + name, + subagentType ?? null, + prompt ? prompt.slice(0, 500) : null, + now, + parentId, + ], + ); + return agentId; +} + +async function matchSubagent( + tx: PgliteExecutor, + sessionId: string, + data: HookData, +): Promise { + const result = await tx.query<{ + id: string; + name: string | null; + subagent_type: string | null; + task: string | null; + }>( + "SELECT id, name, subagent_type, task FROM agents WHERE session_id = $1 AND type = 'subagent' AND status = 'working' ORDER BY started_at DESC", + [sessionId], + ); + const candidates = result.rows; + if (candidates.length === 0) { + return null; + } + const prefix = strOf(data.description) ?? strOf(data.agent_type) ?? strOf(data.subagent_type); + if (prefix) { + const byName = candidates.find((a) => a.name != null && a.name.startsWith(prefix)); + if (byName) return byName.id; + } + if (data.agent_type) { + const byType = candidates.find((a) => a.subagent_type === data.agent_type); + if (byType) return byType.id; + } + if (data.prompt) { + const task = String(data.prompt).slice(0, 500); + const byTask = candidates.find((a) => a.task === task); + if (byTask) return byTask.id; + } + return candidates[0].id; +} + +async function sweepStaleSessions( + tx: PgliteExecutor, + currentSessionId: string, + now: string, + staleMinutes: number, +): Promise { + const cutoff = new Date(Date.now() - staleMinutes * 60_000).toISOString(); + const stale = await tx.query<{ id: string }>( + "SELECT id FROM sessions WHERE status = 'active' AND id != $1 AND updated_at < $2", + [currentSessionId, cutoff], + ); + for (const { id } of stale.rows) { + await tx.query( + "UPDATE agents SET status = 'completed', ended_at = $1, updated_at = $1 WHERE session_id = $2 AND status NOT IN ('completed', 'error')", + [now, id], + ); + await tx.query( + "UPDATE sessions SET status = 'abandoned', ended_at = $1, updated_at = $1 WHERE id = $2", + [now, id], + ); + } +} + +function buildImportMetadata(session: NormalizedSession, harness: Harness): string { + return JSON.stringify({ + version: session.version ?? null, + slug: session.slug ?? null, + gitBranch: session.gitBranch ?? null, + userMessages: session.userMessages ?? 0, + assistantMessages: session.assistantMessages ?? 0, + entrypoint: session.entrypoint ?? harness, + permissionMode: session.permissionMode ?? null, + thinkingBlockCount: session.thinkingBlockCount ?? 0, + teams: session.teams ?? [], + plans: session.plans ?? [], + usageExtras: session.usageExtras ?? { + service_tiers: [], + speeds: [], + inference_geos: [], + }, + compactions: session.compactions ?? [], + messages: session.messages ?? [], + tokenSeries: session.tokenSeries ?? [], + diffStats: session.diffStats ?? null, + slashCommands: session.slashCommands ?? [], + artifacts: session.artifacts ?? { prs: [], issues: [], repo: null }, + }); +} + +function importEventData(input: unknown): string | null { + if (input == null) return null; + let text: string; + try { + text = JSON.stringify(input); + } catch { + return null; + } + if (text.length > MAX_EVENT_DATA_BYTES) { + return JSON.stringify({ truncated: true, bytes: text.length }); + } + return text; +} + +function subagentName(tu: NormalizedToolUse): string { + const input = (tu.input ?? {}) as Record; + const description = strOf(input.description); + const subagentType = strOf(input.subagent_type); + const prompt = strOf(input.prompt); + return ( + description ?? + subagentType ?? + (prompt ? prompt.split("\n")[0].slice(0, 60) : undefined) ?? + "Subagent" + ); +} + +function strOf(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function truncate(value: string | null | undefined, max: number): string | null { + if (typeof value !== "string" || value.length === 0) return null; + return value.length > max ? value.slice(0, max) : value; +} + +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} diff --git a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts b/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts deleted file mode 100644 index 71bf0b05..00000000 --- a/apps/desktop/src/main/database/sqlite-to-pglite-migration.ts +++ /dev/null @@ -1,590 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import { constants as fsConstants } from "node:fs"; -import { - access, - mkdtemp, - readdir, - rename, - rm, - stat, - utimes, -} from "node:fs/promises"; -import path from "node:path"; -import { PGlite, type Results } from "@electric-sql/pglite"; - -const BACKUP_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; -const PGLITE_DIRECTORY_SUFFIX = ".pgdata"; -const BATCH_SIZE = 500; - -const TABLES = [ - { - name: "sessions", - conflictTarget: "id", - columns: [ - "id", - "name", - "status", - "cwd", - "model", - "started_at", - "updated_at", - "ended_at", - "awaiting_input_since", - "metadata", - "harness", - "billing_mode", - "user_id", - "organization_id", - ], - }, - { - name: "agents", - conflictTarget: "id", - columns: [ - "id", - "session_id", - "name", - "type", - "subagent_type", - "status", - "task", - "current_tool", - "started_at", - "updated_at", - "ended_at", - "awaiting_input_since", - "parent_agent_id", - "metadata", - "user_id", - "organization_id", - ], - }, - { - name: "events", - conflictTarget: "id", - columns: [ - "id", - "session_id", - "agent_id", - "event_type", - "tool_name", - "summary", - "data", - "created_at", - "user_id", - "organization_id", - ], - }, - { - name: "token_usage", - conflictTarget: "session_id, model", - columns: [ - "session_id", - "model", - "input_tokens", - "output_tokens", - "cache_read_tokens", - "cache_write_tokens", - "raw_input", - "raw_output", - "raw_cache_read", - "raw_cache_write", - "created_at", - "updated_at", - "user_id", - "organization_id", - ], - }, -] as const; - -type TableName = (typeof TABLES)[number]["name"]; -type TableCounts = Record; - -export type SqliteToPgliteMigrationResult = - | { - status: "skipped"; - reason: "sqlite_missing" | "already_migrated"; - sqlitePath: string; - pgliteDataDir: string; - } - | { - status: "migrated"; - sqlitePath: string; - sqliteBackupPath: string | null; - pgliteDataDir: string; - rowCounts: TableCounts; - } - | { - status: "failed"; - sqlitePath: string; - pgliteDataDir: string; - error: string; - failedAt: string; - }; - -export interface SqliteToPgliteMigrationOptions { - sqlitePath: string; - pgliteDataDir?: string; - now?: () => Date; - log?: (message: string) => void; - /** - * When true, skip the final rename of the SQLite source to .bak. - * Used during startup migration so the existing SQLite runtime can - * continue serving reads and writes until the stores are migrated - * to PGlite. - */ - keepSource?: boolean; -} - -interface PgliteExecutor { - exec(query: string): Promise; - query = Record>( - query: string, - params?: unknown[], - ): Promise>; -} - -interface PgliteClient extends PgliteExecutor { - transaction(callback: (tx: PgliteExecutor) => Promise): Promise; - close(): Promise; -} - -export function resolvePgliteDataDir(sqlitePath: string): string { - const parsed = path.parse(sqlitePath); - return path.join(parsed.dir, `${parsed.name}${PGLITE_DIRECTORY_SUFFIX}`); -} - -const BACKUP_NAME_REGEX = /^(.+\.bak)(\.\d+)?$/; - -export async function cleanupExpiredSqliteBackups( - sqlitePath: string, - now = new Date(), - retentionMs = BACKUP_RETENTION_MS, -): Promise { - const dir = path.dirname(sqlitePath); - const basename = path.basename(sqlitePath); - let removed = 0; - - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return 0; - } - - await Promise.all( - entries - .filter( - (entry) => - entry === `${basename}.bak` || - BACKUP_NAME_REGEX.test(entry) && entry.startsWith(`${basename}.bak`), - ) - .map(async (entry) => { - const backupPath = path.join(dir, entry); - const info = await stat(backupPath).catch(() => null); - if (!info?.isFile()) { - return; - } - if (now.getTime() - info.mtime.getTime() < retentionMs) { - return; - } - await rm(backupPath, { force: true }); - removed += 1; - }), - ); - - return removed; -} - -export async function migrateSqliteToPglite( - options: SqliteToPgliteMigrationOptions, -): Promise { - const sqlitePath = options.sqlitePath; - const pgliteDataDir = options.pgliteDataDir ?? resolvePgliteDataDir(sqlitePath); - const log = options.log ?? (() => {}); - const stampTime = options.now?.() ?? new Date(); - - await cleanupExpiredSqliteBackups(sqlitePath, stampTime); - - const backupExists = await fileExists(`${sqlitePath}.bak`); - const pgdataExists = await fileExists(pgliteDataDir); - - if (!(await fileExists(sqlitePath))) { - return { - status: "skipped", - reason: backupExists && pgdataExists - ? "already_migrated" - : "sqlite_missing", - sqlitePath, - pgliteDataDir, - }; - } - - if (backupExists && pgdataExists) { - return { - status: "skipped", - reason: "already_migrated", - sqlitePath, - pgliteDataDir, - }; - } - - const backupPath = `${sqlitePath}.bak`; - - let sqlite: DatabaseSync | null = null; - let pglite: PgliteClient | null = null; - try { - const stagingDir = await mkdtemp( - path.join(path.dirname(pgliteDataDir), ".pglite-staging-"), - ); - try { - sqlite = new DatabaseSync(sqlitePath); - sqlite.exec("BEGIN"); - assertAllTablesManaged(sqlite); - pglite = await PGlite.create(stagingDir); - - const sourceSchema = readSqliteSchema(sqlite); - const sourceCounts = readSourceCounts(sqlite, sourceSchema); - await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts); - sqlite.exec("COMMIT"); - - sqlite.close(); - sqlite = null; - await pglite.close(); - pglite = null; - - let sqliteBackupPath: string | null = null; - if (options.keepSource) { - log( - `SQLite to PGlite migration succeeded: db=${sanitizePath(sqlitePath)}, source preserved for runtime`, - ); - } else { - await rotateExistingBackup(backupPath); - await rename(sqlitePath, backupPath); - await utimes(backupPath, stampTime, stampTime); - sqliteBackupPath = backupPath; - log( - `SQLite to PGlite migration succeeded: db=${sanitizePath(sqlitePath)}, backup stamped at ${stampTime.toISOString()}`, - ); - } - - await rm(pgliteDataDir, { recursive: true, force: true }).catch( - () => {}, - ); - await rename(stagingDir, pgliteDataDir); - - return { - status: "migrated", - sqlitePath, - sqliteBackupPath, - pgliteDataDir, - rowCounts: sourceCounts, - }; - } finally { - await rm(stagingDir, { recursive: true, force: true }).catch( - () => {}, - ); - } - } catch (error) { - try { - sqlite?.exec("ROLLBACK"); - } catch { - /* ignore rollback failure */ - } - log( - `SQLite to PGlite migration failed: sqlite=${sanitizePath(sqlitePath)}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`, - ); - return { - status: "failed", - sqlitePath, - pgliteDataDir, - error: sanitizeError(error), - failedAt: new Date().toISOString(), - }; - } finally { - try { - sqlite?.close(); - } catch { - /* ignore close failure */ - } - try { - await pglite?.close(); - } catch { - /* ignore close failure */ - } - } -} - -async function rotateExistingBackup(backupPath: string): Promise { - if (!(await fileExists(backupPath))) { - return; - } - const rotatedPath = `${backupPath}.${Date.now()}`; - await rename(backupPath, rotatedPath); -} - -async function initializeAndCopy( - sqlite: DatabaseSync, - pglite: PgliteClient, - sourceSchema: Map>, - sourceCounts: TableCounts, -): Promise { - await pglite.transaction(async (tx) => { - await tx.exec(PGLITE_SCHEMA); - await tx.exec(` - TRUNCATE TABLE - events, - agents, - token_usage, - sessions, - agent_database_metadata - RESTART IDENTITY CASCADE; - `); - - for (const table of TABLES) { - const sourceColumns = sourceSchema.get(table.name); - if (!sourceColumns) { - continue; - } - const columns = table.columns.filter((column) => sourceColumns.has(column)); - if (columns.length === 0) { - continue; - } - - const selectBatchStmt = sqlite.prepare( - `SELECT ${columns.join(", ")} FROM ${table.name} ORDER BY rowid LIMIT ? OFFSET ?`, - ); - let offset = 0; - while (true) { - const batch = selectBatchStmt.all(BATCH_SIZE, offset) as Record< - string, - unknown - >[]; - if (batch.length === 0) { - break; - } - await batchInsertRows( - tx, - table.name, - table.conflictTarget, - columns, - batch, - ); - offset += batch.length; - } - } - - const destinationCounts = await readDestinationCounts(tx); - for (const table of TABLES) { - if (sourceCounts[table.name] !== destinationCounts[table.name]) { - throw new Error( - `row count mismatch for ${table.name}: sqlite=${sourceCounts[table.name]} pglite=${destinationCounts[table.name]}`, - ); - } - } - - await tx.query( - ` - INSERT INTO agent_database_metadata (key, value) - VALUES ($1, $2) - `, - [ - "sqlite_to_pglite_migrated_at", - JSON.stringify({ - migratedAt: new Date().toISOString(), - rowCounts: destinationCounts, - }), - ], - ); - }); -} - -async function batchInsertRows( - pglite: PgliteExecutor, - tableName: string, - conflictTarget: string, - columns: readonly string[], - rows: Record[], -): Promise { - const params: unknown[] = []; - const valueRows: string[] = []; - - for (const row of rows) { - const rowParams = columns.map((col) => { - params.push(row[col] ?? null); - return `$${params.length}`; - }); - valueRows.push(`(${rowParams.join(", ")})`); - } - - await pglite.query( - `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES ${valueRows.join(", ")} ON CONFLICT (${conflictTarget}) DO NOTHING`, - params, - ); -} - -function readSqliteSchema(sqlite: DatabaseSync): Map> { - const schema = new Map>(); - for (const table of TABLES) { - const rows = sqlite.prepare(`PRAGMA table_info(${table.name})`).all() as Array<{ - name: string; - }>; - if (rows.length === 0) { - continue; - } - schema.set(table.name, new Set(rows.map((row) => row.name))); - } - return schema; -} - -function readSourceCounts( - sqlite: DatabaseSync, - sourceSchema: Map>, -): TableCounts { - return Object.fromEntries( - TABLES.map((table) => [ - table.name, - sourceSchema.has(table.name) - ? ((sqlite.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get() as { - count: number; - }).count ?? 0) - : 0, - ]), - ) as TableCounts; -} - -async function readDestinationCounts(pglite: PgliteExecutor): Promise { - const entries: Array<[TableName, number]> = []; - for (const table of TABLES) { - const result = await pglite.query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM ${table.name}`, - ); - entries.push([table.name, Number(result.rows[0]?.count ?? 0)]); - } - return Object.fromEntries(entries) as TableCounts; -} - -function assertAllTablesManaged(sqlite: DatabaseSync): void { - const tableNames = new Set(TABLES.map((t) => t.name)); - const rows = sqlite.prepare( - `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, - ).all() as { name: string }[]; - const unmanaged = rows.filter((row) => !tableNames.has(row.name)); - if (unmanaged.length > 0) { - throw new Error( - `Unmanaged table(s) found in source SQLite: ${unmanaged.map((r) => r.name).join(", ")}. Add these to TABLES before migrating.`, - ); - } -} - -async function fileExists(filePath: string): Promise { - try { - await access(filePath, fsConstants.F_OK); - return true; - } catch { - return false; - } -} - -function sanitizePath(filePath: string): string { - return path.basename(filePath); -} - -function sanitizeError(error: unknown): string { - if (error instanceof Error) { - return `${error.name}: ${error.message}`; - } - return String(error); -} - -const PGLITE_SCHEMA = ` -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - name TEXT, - status TEXT NOT NULL DEFAULT 'running', - cwd TEXT, - model TEXT, - started_at TEXT, - updated_at TEXT, - ended_at TEXT, - awaiting_input_since TEXT, - metadata TEXT, - harness TEXT, - billing_mode TEXT, - user_id TEXT, - organization_id TEXT -); - -CREATE TABLE IF NOT EXISTS agents ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - name TEXT, - type TEXT, - subagent_type TEXT, - status TEXT NOT NULL DEFAULT 'running', - task TEXT, - current_tool TEXT, - started_at TEXT, - updated_at TEXT, - ended_at TEXT, - awaiting_input_since TEXT, - parent_agent_id TEXT, - metadata TEXT, - user_id TEXT, - organization_id TEXT -); - -CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id); -CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); -CREATE INDEX IF NOT EXISTS idx_agents_type ON agents(type); -CREATE INDEX IF NOT EXISTS idx_agents_parent ON agents(parent_agent_id) WHERE parent_agent_id IS NOT NULL; - -CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - agent_id TEXT, - event_type TEXT NOT NULL, - tool_name TEXT, - summary TEXT, - data TEXT, - created_at TEXT, - user_id TEXT, - organization_id TEXT -); - -CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id); -CREATE INDEX IF NOT EXISTS idx_events_agent_id ON events(agent_id); -CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); -CREATE INDEX IF NOT EXISTS idx_events_tool_name ON events(tool_name) WHERE tool_name IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_events_session_tool ON events(session_id, created_at) WHERE tool_name IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); -CREATE INDEX IF NOT EXISTS idx_events_tool_created ON events(created_at, tool_name) WHERE tool_name IS NOT NULL; - -CREATE TABLE IF NOT EXISTS token_usage ( - session_id TEXT NOT NULL, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, - raw_input INTEGER NOT NULL DEFAULT 0, - raw_output INTEGER NOT NULL DEFAULT 0, - raw_cache_read INTEGER NOT NULL DEFAULT 0, - raw_cache_write INTEGER NOT NULL DEFAULT 0, - created_at TEXT DEFAULT (now()::text), - updated_at TEXT, - user_id TEXT, - organization_id TEXT, - PRIMARY KEY (session_id, model) -); - -CREATE INDEX IF NOT EXISTS idx_token_usage_session ON token_usage(session_id); - -CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC); -CREATE INDEX IF NOT EXISTS idx_sessions_status_started_at ON sessions(status, started_at DESC); - -CREATE TABLE IF NOT EXISTS agent_database_metadata ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -`; diff --git a/apps/desktop/test/pglite-agent-dashboard-database.test.ts b/apps/desktop/test/pglite-agent-dashboard-database.test.ts new file mode 100644 index 00000000..7d1f9fa4 --- /dev/null +++ b/apps/desktop/test/pglite-agent-dashboard-database.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { openPgliteAgentDatabase } from "../src/main/database/pglite.js"; + +test("PGlite dashboard database starts empty and fills from hook events", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "agent-dashboard-pglite-")); + const dataDir = path.join(dir, "agent-dashboard.pgdata"); + const changed: string[] = []; + + const db = await openPgliteAgentDatabase({ + dataDir, + detectBillingMode: () => "metered_api", + emit: (sessionId) => changed.push(sessionId), + now: () => "2026-06-07T12:00:00.000Z", + }); + try { + assert.deepEqual(await db.sessions.getAll(), []); + + const processed = await db.processEvent( + "SessionStart", + { + session_id: "pglite-session-1", + cwd: "/workspace/project", + model: "claude-sonnet-4-5", + }, + "claude", + ); + + assert.equal(processed, true); + assert.deepEqual(changed, ["pglite-session-1"]); + + const session = await db.sessions.getById("pglite-session-1"); + assert.equal(session?.id, "pglite-session-1"); + assert.equal(session?.status, "active"); + assert.equal(session?.harness, "claude"); + assert.equal(session?.billingMode, "metered_api"); + + const agents = await db.agents.getBySession("pglite-session-1"); + assert.equal(agents.length, 1); + assert.equal(agents[0].id, "pglite-session-1-main"); + + const events = await db.events.getBySession("pglite-session-1"); + assert.equal(events.length, 1); + assert.equal(events[0].eventType, "SessionStart"); + } finally { + await db.close(); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/test/sqlite-to-pglite-migration.test.ts b/apps/desktop/test/sqlite-to-pglite-migration.test.ts deleted file mode 100644 index c8f5d845..00000000 --- a/apps/desktop/test/sqlite-to-pglite-migration.test.ts +++ /dev/null @@ -1,503 +0,0 @@ -import assert from "node:assert/strict"; -import { DatabaseSync } from "node:sqlite"; -import { - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, - utimesSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { PGlite } from "@electric-sql/pglite"; -import { - cleanupExpiredSqliteBackups, - migrateSqliteToPglite, - resolvePgliteDataDir, -} from "../src/main/database/sqlite-to-pglite-migration.js"; -import { prepareAgentDashboardDatabaseStartup } from "../src/main/agent-dashboard-database-startup.js"; - -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "cl-pglite-migration-")); -} - -function seedSqlite(dbPath: string): void { - const db = new DatabaseSync(dbPath); - try { - db.exec(` - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - name TEXT, - status TEXT NOT NULL DEFAULT 'running', - cwd TEXT, - model TEXT, - started_at TEXT, - updated_at TEXT, - ended_at TEXT, - awaiting_input_since TEXT, - metadata TEXT, - harness TEXT, - billing_mode TEXT, - user_id TEXT, - organization_id TEXT - ); - - CREATE TABLE agents ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - name TEXT, - type TEXT, - subagent_type TEXT, - status TEXT NOT NULL DEFAULT 'running', - task TEXT, - current_tool TEXT, - started_at TEXT, - updated_at TEXT, - ended_at TEXT, - awaiting_input_since TEXT, - parent_agent_id TEXT, - metadata TEXT, - user_id TEXT, - organization_id TEXT - ); - - CREATE TABLE events ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - agent_id TEXT, - event_type TEXT NOT NULL, - tool_name TEXT, - summary TEXT, - data TEXT, - created_at TEXT, - user_id TEXT, - organization_id TEXT - ); - - CREATE TABLE token_usage ( - session_id TEXT NOT NULL, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, - raw_input INTEGER NOT NULL DEFAULT 0, - raw_output INTEGER NOT NULL DEFAULT 0, - raw_cache_read INTEGER NOT NULL DEFAULT 0, - raw_cache_write INTEGER NOT NULL DEFAULT 0, - created_at TEXT, - updated_at TEXT, - user_id TEXT, - organization_id TEXT, - PRIMARY KEY (session_id, model) - ); - `); - db.prepare(` - INSERT INTO sessions ( - id, name, status, cwd, model, started_at, updated_at, harness, - billing_mode, user_id, organization_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "session-1", - "Migration fixture", - "completed", - "/repo", - "claude-sonnet-4-6", - "2026-06-01T00:00:00.000Z", - "2026-06-01T00:01:00.000Z", - "claude", - "api", - "user-1", - "org-1", - ); - db.prepare(` - INSERT INTO agents ( - id, session_id, name, type, status, started_at, updated_at, user_id, organization_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "session-1-main", - "session-1", - "main", - "main", - "completed", - "2026-06-01T00:00:00.000Z", - "2026-06-01T00:01:00.000Z", - "user-1", - "org-1", - ); - db.prepare(` - INSERT INTO events ( - id, session_id, agent_id, event_type, tool_name, summary, data, - created_at, user_id, organization_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "event-1", - "session-1", - "session-1-main", - "PreToolUse", - "Bash", - "Ran command", - "{\"ok\":true}", - "2026-06-01T00:00:30.000Z", - "user-1", - "org-1", - ); - db.prepare(` - INSERT INTO token_usage ( - session_id, model, input_tokens, output_tokens, cache_read_tokens, - cache_write_tokens, raw_input, raw_output, raw_cache_read, - raw_cache_write, created_at, updated_at, user_id, organization_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "session-1", - "claude-sonnet-4-6", - 100, - 20, - 5, - 1, - 100, - 20, - 5, - 1, - "2026-06-01T00:00:00.000Z", - "2026-06-01T00:01:00.000Z", - "user-1", - "org-1", - ); - } finally { - db.close(); - } -} - -function insertAdditionalEvents(dbPath: string, count: number): void { - const db = new DatabaseSync(dbPath); - try { - const insert = db.prepare(` - INSERT INTO events ( - id, session_id, agent_id, event_type, tool_name, summary, data, - created_at, user_id, organization_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - db.exec("BEGIN"); - try { - for (let i = 0; i < count; i += 1) { - insert.run( - `event-extra-${i}`, - "session-1", - "session-1-main", - "PostToolUse", - "Read", - `Batch event ${i}`, - JSON.stringify({ index: i }), - `2026-06-01T00:${String(i % 60).padStart(2, "0")}:00.000Z`, - "user-1", - "org-1", - ); - } - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } finally { - db.close(); - } -} - -test("migrateSqliteToPglite copies rows, preserves attribution columns, and renames SQLite to .bak", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - - const result = await migrateSqliteToPglite({ sqlitePath }); - - assert.equal(result.status, "migrated"); - assert.equal(existsSync(sqlitePath), false, "SQLite source should be renamed"); - assert.equal(existsSync(`${sqlitePath}.bak`), true, "SQLite backup should remain"); - assert.deepEqual(result.status === "migrated" ? result.rowCounts : {}, { - sessions: 1, - agents: 1, - events: 1, - token_usage: 1, - }); - - const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); - try { - const sessions = await pg.query<{ - id: string; - user_id: string | null; - organization_id: string | null; - }>("SELECT id, user_id, organization_id FROM sessions"); - assert.deepEqual(sessions.rows, [ - { id: "session-1", user_id: "user-1", organization_id: "org-1" }, - ]); - - const tokenUsage = await pg.query<{ input_tokens: number; output_tokens: number }>( - "SELECT input_tokens, output_tokens FROM token_usage", - ); - assert.deepEqual(tokenUsage.rows, [{ input_tokens: 100, output_tokens: 20 }]); - } finally { - await pg.close(); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite copies large tables in multiple bounded batches", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - insertAdditionalEvents(sqlitePath, 1000); - - const result = await migrateSqliteToPglite({ sqlitePath }); - - assert.equal(result.status, "migrated"); - assert.equal(result.status === "migrated" ? result.rowCounts.events : 0, 1001); - - const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); - try { - const eventCount = await pg.query<{ count: string }>( - "SELECT COUNT(*)::text AS count FROM events", - ); - assert.equal(Number(eventCount.rows[0]?.count), 1001); - } finally { - await pg.close(); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite returns failed and leaves SQLite intact with unmanaged source tables", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - const db = new DatabaseSync(sqlitePath); - db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)"); - db.close(); - const messages: string[] = []; - - const result = await migrateSqliteToPglite({ - sqlitePath, - log: (message) => messages.push(message), - }); - - assert.equal(result.status, "failed"); - assert.ok(messages.length > 0); - assert.equal( - messages.some((message) => message.includes(sqlitePath)), - false, - "failure logs must not include absolute SQLite paths", - ); - assert.equal( - existsSync(sqlitePath), - true, - "SQLite source must remain usable", - ); - assert.equal( - existsSync(`${sqlitePath}.bak`), - false, - "failed migration must not create backup", - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite skips when backup and pgdata exist", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - writeFileSync(`${sqlitePath}.bak`, "backup"); - rmSync(resolvePgliteDataDir(sqlitePath), { recursive: true, force: true }); - mkdirSync(resolvePgliteDataDir(sqlitePath)); - - const result = await migrateSqliteToPglite({ sqlitePath }); - - assert.equal(result.status, "skipped"); - assert.equal(result.status === "skipped" ? result.reason : "", "already_migrated"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite does not delete live SQLite when backup and pgdata already exist", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - writeFileSync(`${sqlitePath}.bak`, "backup"); - mkdirSync(resolvePgliteDataDir(sqlitePath)); - - const result = await migrateSqliteToPglite({ sqlitePath }); - - assert.equal(result.status, "skipped"); - assert.equal(result.status === "skipped" ? result.reason : "", "already_migrated"); - assert.equal(existsSync(sqlitePath), true, "live SQLite must not be deleted"); - - const db = new DatabaseSync(sqlitePath); - try { - const row = db.prepare("SELECT id FROM sessions WHERE id = ?").get("session-1") as - | { id: string } - | undefined; - assert.equal(row?.id, "session-1"); - } finally { - db.close(); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite returns sqlite_missing when only .bak exists without .pgdata", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - writeFileSync(`${sqlitePath}.bak`, "backup"); - - const result = await migrateSqliteToPglite({ sqlitePath }); - - assert.equal(result.status, "skipped"); - assert.equal(result.status === "skipped" ? result.reason : "", "sqlite_missing"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("migrateSqliteToPglite with keepSource preserves the SQLite source file", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - - const result = await migrateSqliteToPglite({ - sqlitePath, - keepSource: true, - }); - - assert.equal(result.status, "migrated"); - assert.equal(result.sqliteBackupPath, null); - assert.equal( - existsSync(sqlitePath), - true, - "SQLite source kept when keepSource is true", - ); - assert.equal( - existsSync(`${sqlitePath}.bak`), - false, - "no backup created when keepSource is true", - ); - - const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath)); - try { - const sessions = await pg.query<{ id: string }>( - "SELECT id FROM sessions", - ); - assert.equal(sessions.rows.length, 1); - } finally { - await pg.close(); - } - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("prepareAgentDashboardDatabaseStartup leaves SQLite live for the SQLite backend", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - - const result = await prepareAgentDashboardDatabaseStartup({ - userDataPath: dir, - backend: "sqlite", - }); - - assert.equal(result.backend, "sqlite"); - assert.equal(existsSync(sqlitePath), true, "SQLite runtime must keep its live DB"); - assert.equal(existsSync(`${sqlitePath}.bak`), false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("prepareAgentDashboardDatabaseStartup kicks off background migration with PGlite backend", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - seedSqlite(sqlitePath); - - const result = await prepareAgentDashboardDatabaseStartup({ - userDataPath: dir, - backend: "pglite", - }); - - assert.equal(result.backend, "pglite"); - assert.equal(result.migration, undefined); - assert.ok(result.migrationPromise, "migration promise exists"); - const migration = await result.migrationPromise; - assert.equal(migration.status, "migrated"); - assert.equal( - existsSync(sqlitePath), - true, - "SQLite preserved for sync runtime", - ); - assert.equal(existsSync(`${sqlitePath}.bak`), false, "no backup created"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("prepareAgentDashboardDatabaseStartup logs failure without affecting SQLite when PGlite migration fails", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - const db = new DatabaseSync(sqlitePath); - db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)"); - db.close(); - - const result = await prepareAgentDashboardDatabaseStartup({ - userDataPath: dir, - backend: "pglite", - }); - - assert.equal(result.backend, "pglite"); - const migration = await result.migrationPromise!; - assert.equal(migration.status, "failed"); - assert.equal(existsSync(sqlitePath), true, "failure keeps SQLite live"); - assert.equal(existsSync(`${sqlitePath}.bak`), false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("cleanupExpiredSqliteBackups deletes backups once the 30 day safety window has elapsed", async () => { - const dir = makeTempDir(); - try { - const sqlitePath = path.join(dir, "agent-dashboard.sqlite"); - const backupPath = `${sqlitePath}.bak`; - writeFileSync(backupPath, "backup"); - const old = new Date("2026-01-01T00:00:00.000Z"); - utimesSync(backupPath, old, old); - - const removed = await cleanupExpiredSqliteBackups( - sqlitePath, - new Date("2026-02-01T00:00:00.000Z"), - ); - - assert.equal(removed, 1); - assert.equal(existsSync(backupPath), false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); From 292d589894d1b5f415d77f3fd477a06d47d6b531 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 00:45:56 -0500 Subject: [PATCH 05/20] FEA-1550: Drop legacy dashboard SQLite and sidecar - Remove the generated Agent Monitor sidecar, third-party dashboard package wiring, and ClosedLoop-owned SQLite repository. - Keep Agent Dashboard on the PGlite runtime with fresh ingest state, ungated session import/sync, always-on chunked sync, and sanitized payloads. - Add PGlite coverage for dashboard parity, metered usage rows, and sync sanitization while retaining OpenCode ingestion from its external database. Testing: Desktop typecheck, lint, and focused dashboard/sync/reconciliation/parser tests passed. Risks: Full desktop test suite was not rerun after removing the legacy sidecar and SQLite-only tests. --- apps/desktop/electron-builder.yml | 14 - apps/desktop/package.json | 24 +- .../agent-monitor-billing/billing-mode.js | 262 - .../agent-monitor-billing/package.json | 5 - .../agent-monitor-client/Dashboard.tsx | 2466 --------- .../scripts/agent-monitor-client/Sessions.tsx | 453 -- .../scripts/agent-monitor-client/Settings.tsx | 1165 ----- .../agent-monitor-client/StatusBadge.tsx | 103 - .../lib/closedloop-ledger.ts | 120 - .../client/sessioncard.badge.replace.txt | 4 - .../client/sessions.filterui.find.txt | 2 - .../client/sessions.filterui.replace.txt | 19 - .../client/sessions.loadrows.find.txt | 3 - .../client/sessions.loadrows.legacy.find.txt | 6 - .../client/sessions.loadrows.replace.txt | 4 - .../client/sessions.loadtop.find.txt | 7 - .../client/sessions.loadtop.legacy.find.txt | 9 - .../client/sessions.loadtop.replace.txt | 15 - .../client/sessions.rowbadge.find.txt | 2 - .../client/sessions.rowbadge.replace.txt | 3 - .../client/sessions.state.replace.txt | 14 - .../client/statusbadge.append.tsx | 17 - .../scripts/agent-monitor-codex/codex-home.js | 103 - .../agent-monitor-codex/codex-import.js | 113 - .../agent-monitor-codex/codex-parser.js | 368 -- .../agent-monitor-codex/codex-watcher.js | 192 - .../agent-monitor-copilot/copilot-home.js | 135 - .../agent-monitor-copilot/copilot-import.js | 111 - .../agent-monitor-copilot/copilot-parser.js | 493 -- .../agent-monitor-copilot/copilot-watcher.js | 177 - .../agent-monitor-cost/cost-pricing.js | 176 - .../scripts/agent-monitor-cost/package.json | 5 - .../agent-monitor-cursor/cursor-home.js | 81 - .../agent-monitor-cursor/cursor-import.js | 91 - .../agent-monitor-cursor/cursor-parser.js | 208 - .../agent-monitor-cursor/cursor-watcher.js | 134 - .../scripts/agent-monitor-embed/App.tsx | 67 - .../scripts/agent-monitor-embed/Layout.tsx | 127 - .../agent-monitor-embed/tailwind.config.js | 67 - .../agent-monitor-opencode/opencode-home.js | 41 - .../agent-monitor-opencode/opencode-import.js | 116 - .../agent-monitor-opencode/opencode-parser.js | 243 - .../opencode-watcher.js | 104 - .../__tests__/catalog-action-handler.test.js | 181 - .../__tests__/catalog-fetcher.test.js | 80 - .../__tests__/catalog-store.test.js | 264 - .../__tests__/install-orchestrator.test.js | 326 -- .../__tests__/pack-scanner.test.js | 1178 ----- .../catalog-action-handler.js | 137 - .../agent-monitor-packs/catalog-contents.js | 401 -- .../agent-monitor-packs/catalog-detector.js | 332 -- .../agent-monitor-packs/catalog-fetcher.js | 367 -- .../agent-monitor-packs/catalog-route.js | 247 - .../agent-monitor-packs/catalog-seed.json | 415 -- .../agent-monitor-packs/catalog-store.js | 543 -- .../client/CatalogCard.tsx | 379 -- .../client/CatalogDetail.tsx | 476 -- .../client/InstallModal.tsx | 460 -- .../agent-monitor-packs/client/PackDetail.tsx | 722 --- .../client/PackInstallModalUtils.ts | 42 - .../agent-monitor-packs/client/Packs.tsx | 11 - .../client/PacksCatalog.tsx | 136 - .../client/PacksInstalled.tsx | 279 - .../client/PacksLayout.tsx | 23 - .../agent-monitor-packs/client/Skills.tsx | 259 - .../agent-monitor-packs/client/Sparkline.tsx | 58 - .../agent-monitor-packs/client/SubAgents.tsx | 184 - .../agent-monitor-packs/client/Tools.tsx | 198 - .../install-orchestrator.js | 485 -- .../agent-monitor-packs/pack-scanner.js | 953 ---- .../scripts/agent-monitor-packs/pack-store.js | 566 --- .../scripts/agent-monitor-packs/package.json | 5 - .../agent-monitor-packs/packs-route.js | 94 - .../agent-monitor-packs/skills-route.js | 58 - .../__tests__/plan-extractor.test.js | 402 -- .../agent-monitor-plans/client/Plans.tsx | 302 -- .../client/closedloop-host-flags.ts | 24 - .../scripts/agent-monitor-plans/package.json | 5 - .../agent-monitor-plans/plan-backfill.js | 86 - .../agent-monitor-plans/plan-extractor.js | 355 -- .../scripts/agent-monitor-plans/plan-store.js | 408 -- .../agent-monitor-plans/plans-route.js | 155 - .../__tests__/fixtures/README.md | 112 - .../fixtures/claude-code-session.jsonl | 11 - .../__tests__/fixtures/codex-session.jsonl | 12 - .../__tests__/fixtures/expected-events.json | 80 - .../__tests__/fixtures/loop-pr-link.jsonl | 4 - .../__tests__/fixtures/negatives.jsonl | 22 - .../__tests__/pr-backfill.test.js | 385 -- .../__tests__/pr-extractor.test.js | 66 - .../__tests__/pr-parsers.test.js | 150 - .../__tests__/pull-request-store.test.js | 139 - .../client/PullRequests.tsx | 269 - .../agent-monitor-pull-requests/package.json | 5 - .../pr-backfill.js | 261 - .../pr-extractor.js | 100 - .../agent-monitor-pull-requests/pr-parsers.js | 310 -- .../pull-request-store.js | 236 - .../pull-requests-route.js | 108 - .../agent-monitor-shared/billing-stamp.js | 60 - .../agent-monitor-shared/catchup-cache.js | 165 - .../harness-watcher-utils.js | 30 - .../import-session-utils.js | 42 - .../ingest-orchestrator.js | 119 - .../agent-monitor-shared/ingest-paths.js | 73 - .../agent-monitor-shared/ingest-progress.js | 158 - .../agent-monitor-shared/parser-utils.js | 74 - .../scripts/assert-design-system-boot-off.mjs | 530 -- apps/desktop/scripts/build-agent-monitor.mjs | 4517 ----------------- .../measure-agent-dashboard-storage.mjs | 85 +- apps/desktop/scripts/reset-dashboard-db.mjs | 25 +- apps/desktop/scripts/stage-packaging-app.mjs | 19 - .../agent-dashboard-design-system-runtime.ts | 35 + apps/desktop/src/main/agent-dashboard-mode.ts | 42 - .../src/main/agent-monitor-listener.ts | 7 +- apps/desktop/src/main/agent-monitor-path.ts | 47 - .../desktop/src/main/agent-monitor-sidecar.ts | 597 --- .../src/main/agent-session-sync-service.ts | 407 +- apps/desktop/src/main/app.ts | 215 +- .../src/main/collectors/collector-manager.ts | 3 +- .../src/main/collectors/import-session.ts | 401 -- .../src/main/collectors/ingest-paths.ts | 5 +- apps/desktop/src/main/cost-math.ts | 2 +- .../src/main/cost-reconciliation-service.ts | 2 +- apps/desktop/src/main/database/agents.ts | 104 - apps/desktop/src/main/database/dashboard.ts | 275 - apps/desktop/src/main/database/events.ts | 76 - apps/desktop/src/main/database/index.ts | 93 - .../src/main/database/ipc-validation.ts | 6 +- apps/desktop/src/main/database/lifecycle.ts | 508 -- apps/desktop/src/main/database/pglite.ts | 360 +- apps/desktop/src/main/database/schema.ts | 150 - apps/desktop/src/main/database/sessions.ts | 271 - apps/desktop/src/main/database/token-usage.ts | 106 - apps/desktop/src/main/database/types.ts | 18 - apps/desktop/src/main/index.ts | 29 +- .../desktop/src/main/preload-design-system.ts | 14 + apps/desktop/src/main/settings-store.ts | 13 +- apps/desktop/src/main/window.ts | 78 +- apps/desktop/src/renderer/App.tsx | 30 + .../components/features/CoreFeaturesView.tsx | 388 ++ .../renderer/components/layout/Sidebar.tsx | 12 + .../src/renderer/components/layout/Topbar.tsx | 6 + .../components/settings/SettingsPanel.tsx | 3 +- .../src/renderer/types/desktop-api.d.ts | 16 +- apps/desktop/src/shared/agent-db-contract.ts | 83 +- apps/desktop/src/shared/contracts.ts | 17 +- apps/desktop/src/shared/feature-flags.ts | 19 +- .../agent-monitor/fixtures/agent_packs.json | 46 - .../agent-monitor/fixtures/agents.json | 98 - .../agent-monitor/fixtures/events.json | 74 - .../agent-monitor/fixtures/model_pricing.json | 29 - .../agent-monitor/fixtures/pack_catalog.json | 130 - .../agent-monitor/fixtures/schema.sql | 238 - .../agent-monitor/fixtures/sessions.json | 67 - .../agent-monitor/fixtures/skills.json | 41 - .../agent-monitor/fixtures/token_usage.json | 50 - .../agent-monitor/helpers/audit-tile.ts | 144 - .../agent-monitor/helpers/launch-sidecar.mjs | 154 - .../helpers/playwright-global-setup.ts | 35 - .../helpers/playwright-global-teardown.ts | 28 - .../helpers/playwright-region.ts | 67 - .../agent-monitor/helpers/seed-fixture-db.mjs | 119 - .../inventory/PHASE3-RENDERER-MAP.md | 134 - .../inventory/PHASE3-SIDECAR-REUSE.md | 50 - .../inventory/PHASE3-WEAK-TRIAGE.md | 156 - .../agent-monitor/inventory/audit-runner.mjs | 118 - .../inventory/coverage-classifier.mjs | 328 -- .../agent-monitor/inventory/coverage.json | 2166 -------- .../agent-monitor/inventory/formatters.mjs | 49 - .../inventory/manifest-loader.mjs | 83 - .../agent-monitor/inventory/manifest.json | 1246 ----- .../agent-monitor/inventory/oracles.mjs | 1168 ----- .../agent-monitor/inventory/run-report.mjs | 329 -- .../agent-monitor/inventory/scan-tiles.mjs | 598 --- .../triage/01-total-cost-pattern-override.md | 79 - .../agent-monitor/playwright.audit.config.ts | 16 - .../agent-monitor/playwright.config.ts | 50 - .../api-contract/dashboard.contract.test.mjs | 93 - .../api-contract/packs.contract.test.mjs | 97 - .../stale-on-restart.contract.test.mjs | 135 - .../audit/all-screens.api-audit.test.mjs | 142 - .../audit/bucketed-counts.audit.test.mjs | 175 - .../audit/claude-hooks.contract.test.mjs | 214 - .../audit/codex-parser.contract.test.mjs | 106 - .../audit/copilot-parser.contract.test.mjs | 77 - .../specs/audit/coverage-validator.test.mjs | 215 - .../audit/cursor-parser.contract.test.mjs | 80 - .../specs/audit/dashboard.ui-audit.spec.ts | 53 - .../audit/opencode-parser.contract.test.mjs | 78 - .../specs/audit/pack-detail.audit.test.mjs | 104 - .../audit/per-model-tokens.audit.test.mjs | 335 -- .../audit/pricing-breakdown.audit.test.mjs | 122 - .../specs/audit/session-detail.audit.test.mjs | 104 - .../audit/sessions.per-row.audit.test.mjs | 129 - .../specs/audit/sessions.ui-audit.spec.ts | 48 - .../specs/audit/skills.ui-audit.spec.ts | 37 - .../audit/timezone-bucketing.audit.test.mjs | 161 - .../specs/audit/tools.ui-audit.spec.ts | 40 - .../specs/ui/dashboard-tiles.spec.ts | 67 - .../agent-monitor/specs/ui/packs.spec.ts | 80 - .../specs/ui/pull-requests.spec.ts | 55 - .../agent-monitor/specs/ui/sessions.spec.ts | 70 - .../test/agent-dashboard-boundary.test.ts | 32 +- .../test/agent-monitor-catchup-cache.test.ts | 242 - ...agent-monitor-import-session-utils.test.ts | 193 - .../test/agent-monitor-lifecycle.test.ts | 151 - .../test/agent-monitor-listener.test.ts | 44 +- ...gent-monitor-multi-harness-parsers.test.ts | 314 -- .../test/agent-monitor-sidecar.test.ts | 592 --- .../agent-monitor-sqlite-contention.test.ts | 183 - ...agent-monitor-token-reconciliation.test.ts | 83 - .../test/agent-monitor-wiring-static.test.ts | 1137 ----- .../test/agent-session-pagination.test.ts | 269 - ...session-sync-rate-limit-deadletter.test.ts | 504 -- .../agent-session-sync-sanitization.test.ts | 102 + .../test/agent-session-sync-service.test.ts | 1415 ------ apps/desktop/test/closedloop-ledger.test.ts | 115 - apps/desktop/test/codex-parser.test.ts | 208 - apps/desktop/test/collectors-import.test.ts | 487 -- apps/desktop/test/db-ipc-validation.test.ts | 2 +- apps/desktop/test/feature-flags.test.ts | 38 +- .../helpers/agent-session-sync-test-utils.ts | 148 - apps/desktop/test/ingest-orchestrator.test.ts | 377 +- .../test/pack-install-modal-utils.test.ts | 38 - .../pglite-agent-dashboard-database.test.ts | 211 + .../test/reconciliation-worker.test.ts | 60 - .../test/session-identity-columns.test.ts | 289 -- pnpm-lock.yaml | 934 ---- 229 files changed, 1577 insertions(+), 49368 deletions(-) delete mode 100644 apps/desktop/scripts/agent-monitor-billing/billing-mode.js delete mode 100644 apps/desktop/scripts/agent-monitor-billing/package.json delete mode 100644 apps/desktop/scripts/agent-monitor-client/Dashboard.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-client/Sessions.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-client/Settings.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt delete mode 100644 apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-codex/codex-home.js delete mode 100644 apps/desktop/scripts/agent-monitor-codex/codex-import.js delete mode 100644 apps/desktop/scripts/agent-monitor-codex/codex-parser.js delete mode 100644 apps/desktop/scripts/agent-monitor-codex/codex-watcher.js delete mode 100644 apps/desktop/scripts/agent-monitor-copilot/copilot-home.js delete mode 100644 apps/desktop/scripts/agent-monitor-copilot/copilot-import.js delete mode 100644 apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js delete mode 100644 apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js delete mode 100644 apps/desktop/scripts/agent-monitor-cost/cost-pricing.js delete mode 100644 apps/desktop/scripts/agent-monitor-cost/package.json delete mode 100644 apps/desktop/scripts/agent-monitor-cursor/cursor-home.js delete mode 100644 apps/desktop/scripts/agent-monitor-cursor/cursor-import.js delete mode 100644 apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js delete mode 100644 apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js delete mode 100644 apps/desktop/scripts/agent-monitor-embed/App.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-embed/Layout.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-embed/tailwind.config.js delete mode 100644 apps/desktop/scripts/agent-monitor-opencode/opencode-home.js delete mode 100644 apps/desktop/scripts/agent-monitor-opencode/opencode-import.js delete mode 100644 apps/desktop/scripts/agent-monitor-opencode/opencode-parser.js delete mode 100644 apps/desktop/scripts/agent-monitor-opencode/opencode-watcher.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/__tests__/catalog-action-handler.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/__tests__/catalog-fetcher.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/__tests__/catalog-store.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/__tests__/install-orchestrator.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/__tests__/pack-scanner.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-action-handler.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-contents.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-detector.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-fetcher.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-route.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-seed.json delete mode 100644 apps/desktop/scripts/agent-monitor-packs/catalog-store.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/CatalogCard.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/CatalogDetail.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/InstallModal.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/PackDetail.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/PackInstallModalUtils.ts delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/Packs.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/PacksCatalog.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/PacksInstalled.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/PacksLayout.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/Skills.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/Sparkline.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/SubAgents.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/client/Tools.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-packs/install-orchestrator.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/pack-scanner.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/pack-store.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/package.json delete mode 100644 apps/desktop/scripts/agent-monitor-packs/packs-route.js delete mode 100644 apps/desktop/scripts/agent-monitor-packs/skills-route.js delete mode 100644 apps/desktop/scripts/agent-monitor-plans/__tests__/plan-extractor.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-plans/client/Plans.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-plans/client/closedloop-host-flags.ts delete mode 100644 apps/desktop/scripts/agent-monitor-plans/package.json delete mode 100644 apps/desktop/scripts/agent-monitor-plans/plan-backfill.js delete mode 100644 apps/desktop/scripts/agent-monitor-plans/plan-extractor.js delete mode 100644 apps/desktop/scripts/agent-monitor-plans/plan-store.js delete mode 100644 apps/desktop/scripts/agent-monitor-plans/plans-route.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/README.md delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/claude-code-session.jsonl delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/codex-session.jsonl delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/expected-events.json delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/loop-pr-link.jsonl delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/fixtures/negatives.jsonl delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/pr-backfill.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/pr-extractor.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/pr-parsers.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/__tests__/pull-request-store.test.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/client/PullRequests.tsx delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/package.json delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/pr-backfill.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/pr-extractor.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/pr-parsers.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/pull-request-store.js delete mode 100644 apps/desktop/scripts/agent-monitor-pull-requests/pull-requests-route.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/billing-stamp.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/catchup-cache.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/harness-watcher-utils.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/import-session-utils.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/ingest-orchestrator.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/ingest-paths.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/ingest-progress.js delete mode 100644 apps/desktop/scripts/agent-monitor-shared/parser-utils.js delete mode 100644 apps/desktop/scripts/assert-design-system-boot-off.mjs delete mode 100644 apps/desktop/scripts/build-agent-monitor.mjs delete mode 100644 apps/desktop/src/main/agent-dashboard-mode.ts delete mode 100644 apps/desktop/src/main/agent-monitor-sidecar.ts delete mode 100644 apps/desktop/src/main/collectors/import-session.ts delete mode 100644 apps/desktop/src/main/database/agents.ts delete mode 100644 apps/desktop/src/main/database/dashboard.ts delete mode 100644 apps/desktop/src/main/database/events.ts delete mode 100644 apps/desktop/src/main/database/index.ts delete mode 100644 apps/desktop/src/main/database/lifecycle.ts delete mode 100644 apps/desktop/src/main/database/schema.ts delete mode 100644 apps/desktop/src/main/database/sessions.ts delete mode 100644 apps/desktop/src/main/database/token-usage.ts delete mode 100644 apps/desktop/src/main/database/types.ts create mode 100644 apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/agent_packs.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/agents.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/events.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/model_pricing.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/pack_catalog.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/schema.sql delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/sessions.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/skills.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/fixtures/token_usage.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/audit-tile.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/launch-sidecar.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-setup.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/playwright-global-teardown.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/playwright-region.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/helpers/seed-fixture-db.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-RENDERER-MAP.md delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-SIDECAR-REUSE.md delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/PHASE3-WEAK-TRIAGE.md delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/audit-runner.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/coverage-classifier.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/coverage.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/formatters.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/manifest-loader.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/manifest.json delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/oracles.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/run-report.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/scan-tiles.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/inventory/triage/01-total-cost-pattern-override.md delete mode 100644 apps/desktop/test-e2e/agent-monitor/playwright.audit.config.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/playwright.config.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/api-contract/dashboard.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/api-contract/packs.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/api-contract/stale-on-restart.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/all-screens.api-audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/bucketed-counts.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/claude-hooks.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/codex-parser.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/copilot-parser.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/coverage-validator.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/cursor-parser.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/dashboard.ui-audit.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/opencode-parser.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/pack-detail.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/per-model-tokens.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/pricing-breakdown.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/session-detail.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.per-row.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/sessions.ui-audit.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/skills.ui-audit.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/timezone-bucketing.audit.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/tools.ui-audit.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/ui/dashboard-tiles.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/ui/packs.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/ui/pull-requests.spec.ts delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/ui/sessions.spec.ts delete mode 100644 apps/desktop/test/agent-monitor-catchup-cache.test.ts delete mode 100644 apps/desktop/test/agent-monitor-import-session-utils.test.ts delete mode 100644 apps/desktop/test/agent-monitor-lifecycle.test.ts delete mode 100644 apps/desktop/test/agent-monitor-multi-harness-parsers.test.ts delete mode 100644 apps/desktop/test/agent-monitor-sidecar.test.ts delete mode 100644 apps/desktop/test/agent-monitor-sqlite-contention.test.ts delete mode 100644 apps/desktop/test/agent-monitor-token-reconciliation.test.ts delete mode 100644 apps/desktop/test/agent-monitor-wiring-static.test.ts delete mode 100644 apps/desktop/test/agent-session-pagination.test.ts delete mode 100644 apps/desktop/test/agent-session-sync-rate-limit-deadletter.test.ts create mode 100644 apps/desktop/test/agent-session-sync-sanitization.test.ts delete mode 100644 apps/desktop/test/agent-session-sync-service.test.ts delete mode 100644 apps/desktop/test/closedloop-ledger.test.ts delete mode 100644 apps/desktop/test/codex-parser.test.ts delete mode 100644 apps/desktop/test/collectors-import.test.ts delete mode 100644 apps/desktop/test/helpers/agent-session-sync-test-utils.ts delete mode 100644 apps/desktop/test/pack-install-modal-utils.test.ts delete mode 100644 apps/desktop/test/session-identity-columns.test.ts diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index aaac6ee9..c6f34218 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -14,20 +14,6 @@ extraResources: to: trayIconTemplate.png - from: resources/trayIconTemplate@2x.png to: trayIconTemplate@2x.png - # Generated Claude-Code-Agent-Monitor runtime tree, shipped unpacked - # (outside the asar) so the spawned Node server, the built client, and the - # hook scripts resolve as real files. Built by scripts/build-agent-monitor.mjs - # before packaging (chained into `build`). `client/dist/**/*` (NOT - # `client/**/*`) — the server resolves ../client/dist relative to server/, so - # the server/ <-> client/dist/ layout must be preserved. - - from: .generated/agent-monitor - to: agent-monitor - filter: - - server/**/* - - client/dist/**/* - - scripts/**/* - - package.json - - LICENSE # First-party agent-monitor hook handlers (FEA-1503), shipped unpacked outside # the asar so they resolve as real files. agent-monitor-hooks.ts copies them # into userData at install time; the installed hook command runs them via the diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a5f568c3..bc1c5b2f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,37 +8,21 @@ "main": "dist/main/index.js", "scripts": { "dev": "pnpm build && ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", - "start": "pnpm build:agent-monitor && ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", + "start": "ELECTRON_BIN=$(bash scripts/patch-electron-plist.sh | tail -1) && \"$ELECTRON_BIN\" .", "clean:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "clean:package": "node -e \"require('fs').rmSync('dist-dmg',{recursive:true,force:true})\"", "prebuild": "node -e \"const{execSync:e}=require('child_process'),{writeFileSync:w}=require('fs');const h=e('git rev-parse HEAD').toString().trim();w('src/shared/build-info.ts','// AUTO-GENERATED — do not edit\\nexport const BUILD_COMMIT_HASH = \\\"'+h+'\\\";\\n');\"", - "build": "pnpm clean:dist && pnpm prebuild && tsc -p tsconfig.json && pnpm build:renderer && pnpm build:agent-monitor", + "build": "pnpm clean:dist && pnpm prebuild && tsc -p tsconfig.json && pnpm build:renderer", "build:renderer": "vite build --config vite.renderer.config.ts", - "build:agent-monitor": "node scripts/build-agent-monitor.mjs", "dashboard:reset": "node scripts/reset-dashboard-db.mjs", - "dashboard:reset-packs": "node scripts/reset-dashboard-db.mjs --packs-only", "stage:package": "node scripts/stage-packaging-app.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && pnpm typecheck:renderer", "typecheck:renderer": "tsc -p tsconfig.renderer.json --noEmit", "lint": "eslint src/", "assert:design-system-boundary": "tsx --test test/agent-dashboard-boundary.test.ts", - "test:boot:design-system-off": "node --import tsx scripts/assert-design-system-boot-off.mjs", "measure:agent-dashboard-storage": "node scripts/measure-agent-dashboard-storage.mjs", "verify:electron-binary": "node scripts/ensure-electron-binary.mjs", - "test": "tsx --test --test-concurrency=1 test/*.test.ts && node --test \"scripts/agent-monitor-packs/__tests__/*.test.js\" \"scripts/agent-monitor-pull-requests/__tests__/*.test.js\"", - "pretest:contract": "pnpm build:agent-monitor", - "test:contract": "node --test \"test-e2e/agent-monitor/specs/api-contract/*.test.mjs\"", - "pretest:e2e": "pnpm build:agent-monitor", - "test:e2e": "playwright test --config test-e2e/agent-monitor/playwright.config.ts", - "pretest:audit": "pnpm build:agent-monitor && pnpm audit:scan && pnpm audit:classify", - "audit:scan": "node test-e2e/agent-monitor/inventory/scan-tiles.mjs", - "audit:classify": "node test-e2e/agent-monitor/inventory/coverage-classifier.mjs", - "audit:coverage": "bash scripts/check-audit-coverage.sh", - "test:audit": "node --test \"test-e2e/agent-monitor/specs/audit/*.test.mjs\"", - "pretest:audit:ui": "pnpm build:agent-monitor", - "test:audit:ui": "playwright test --config test-e2e/agent-monitor/playwright.audit.config.ts", - "preaudit:report": "pnpm build:agent-monitor", - "audit:report": "node test-e2e/agent-monitor/inventory/run-report.mjs", + "test": "tsx --test --test-concurrency=1 test/*.test.ts", "package": "pnpm clean:package && pnpm build && pnpm stage:package && node scripts/run-electron-builder.mjs", "release": "pnpm clean:package && pnpm build && pnpm stage:package && node scripts/run-electron-builder.mjs --publish always" }, @@ -47,7 +31,6 @@ "@closedloop-ai/loops-api": ">=0.3.1", "@electric-sql/pglite": "^0.4.6", "@pydantic/genai-prices": "0.0.62", - "agent-dashboard": "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40", "busboy": "^1.6.0", "electron-log": "^5.4.3", "electron-store": "^8.2.0", @@ -73,7 +56,6 @@ "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", "@vitejs/plugin-react": "^5.1.3", - "agent-dashboard-client": "github:hoangsonww/Claude-Code-Agent-Monitor#840c518d7fa69231de049e41b893938228b67e40&path:/client", "autoprefixer": "10.4.20", "electron": "^35.0.2", "electron-builder": "^26.8.1", diff --git a/apps/desktop/scripts/agent-monitor-billing/billing-mode.js b/apps/desktop/scripts/agent-monitor-billing/billing-mode.js deleted file mode 100644 index 1998f560..00000000 --- a/apps/desktop/scripts/agent-monitor-billing/billing-mode.js +++ /dev/null @@ -1,262 +0,0 @@ -/** - * @file billing-mode.js - * @description Canonical billing-mode engine for the agent-monitor sidecar - * (CommonJS). Classifies each tracked session as METERED (real per-token API - * spend) vs SUBSCRIPTION-covered (Claude Pro/Max, ChatGPT/Codex, Copilot seat, - * Cursor Pro) so the dashboard can keep two separate ledgers and never sum a - * hypothetical subscription cost into real headline spend. - * - * CLOSEDLOOP FEA-1434. Mirrors the agent-monitor-cost engine pattern: this CJS - * module is the source of truth that runs inside the generated sidecar tree, - * and `src/shared/billing-mode.ts` is a byte-equal ESM twin for desktop-main - * (which must work with the sidecar disabled). A parity test - * (`test/billing-mode.test.ts`) imports BOTH and asserts identical output so - * the twins cannot drift. - * - * ── Two responsibilities ────────────────────────────────────────────────────── - * 1. CLASSIFICATION (pure, total over the BillingMode union): map a stored - * billing mode → a ledger ("metered" | "subscription" | "unknown"). The - * schema column, relay sync, and UI all rely on this being total. - * 2. DETECTION (pure, dependency-injected): infer the billing mode for a - * harness from credential PRESENCE only. Detection takes injected deps - * ({ env, fileExists, homeDir }) so it is testable and so it can run in - * both the sidecar and desktop-main with the right real implementations. - * - * ── Secret-handling rule (non-negotiable) ───────────────────────────────────── - * Detection checks credential EXISTENCE only. It NEVER reads the contents of - * `~/.claude/.credentials.json`, `~/.codex/auth.json`, or any API-key env var - * beyond a non-empty check, and NEVER logs, echoes, or returns those values. - * The only output is an opaque BillingMode string. - * - * ── Tier granularity ────────────────────────────────────────────────────────── - * The BillingMode union carries tier-specific Anthropic values (pro/max_5x/ - * max_20x) and Codex values for the persisted/synced contract, but existence- - * only detection cannot distinguish tiers (that needs `/status` parsing, out of - * scope for this slice — see PRD-414). So OAuth-present Anthropic resolves to - * `subscription_unknown`; the finer tiers arrive later from `/status` or cloud - * sync. The ledger mapping is total over every value regardless. - */ -"use strict"; - -const path = require("node:path"); - -/** - * Every valid billing mode. Persisted in the sessions.billing_mode column and - * carried on the relay sync contract, so this is a stable, additive list. - * Exported so callers/tests can iterate the full domain. - */ -const BILLING_MODES = [ - "api", - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_api", - "cursor_pro", - "copilot_seat", - "opencode", - "unknown", -]; - -// Real per-token API spend → counts toward headline metered cost. -const METERED_MODES = new Set(["api", "cursor_api"]); -// Subscription-covered → priced only as a hypothetical "would have cost" -// equivalent, NEVER summed into headline spend. -const SUBSCRIPTION_MODES = new Set([ - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_pro", - "copilot_seat", -]); - -/** - * Map a billing mode to its ledger. Total over the union: anything not metered - * or subscription (opencode BYOK, the literal "unknown", or any unrecognized - * future value read from disk/relay) lands in "unknown" so it is neither - * charged nor mislabeled as covered. - * @param {string} mode - * @returns {"metered"|"subscription"|"unknown"} - */ -function billingLedger(mode) { - if (METERED_MODES.has(mode)) return "metered"; - if (SUBSCRIPTION_MODES.has(mode)) return "subscription"; - return "unknown"; -} - -/** True when the mode represents real, per-token API spend. */ -function isMeteredApi(mode) { - return billingLedger(mode) === "metered"; -} - -/** True when the mode is covered by a flat subscription/seat. */ -function isSubscription(mode) { - return billingLedger(mode) === "subscription"; -} - -/** - * ── Ledger accounting (pure) ────────────────────────────────────────────────── - * The two-ledger invariant lives here so the sidecar routes and any future - * desktop-main caller share one definition and cannot diverge. A LedgerTotals - * accumulator carries the three buckets; addLedgerCost() routes one priced row - * into its bucket via billingLedger(); headlineCost() defines what counts as - * real spend. - * - * Headline = metered + unknown (NOT subscription). Rationale: subscription rows - * are a hypothetical "would have cost" and must never inflate real spend, while - * legacy/opencode rows in the unknown bucket are pre-existing real numbers we - * must not silently zero out. Subscription cost stays visible in its own bucket - * for the two-ledger UI; it is simply excluded from the headline sum. - */ - -/** Fresh zeroed accumulator. Shape is the wire contract for cost_by_ledger. */ -function emptyLedgerTotals() { - return { metered: 0, subscription: 0, unknown: 0 }; -} - -/** - * Add one priced row's cost to the bucket its billing mode maps to. Non-finite - * costs (null/undefined/NaN from an unpriced row) are ignored so an unpriced - * model never corrupts a ledger total — it simply does not contribute. Mutates - * and returns `totals` for fold-style accumulation. - * @param {{metered:number,subscription:number,unknown:number}} totals - * @param {string} billingMode - * @param {number} costUsd - */ -function addLedgerCost(totals, billingMode, costUsd) { - if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return totals; - totals[billingLedger(billingMode)] += costUsd; - return totals; -} - -/** - * The headline "real spend" number: metered API spend plus unknown-ledger rows - * (legacy/opencode), explicitly EXCLUDING subscription-covered cost. - * @param {{metered:number,subscription:number,unknown:number}} totals - * @returns {number} - */ -function headlineCost(totals) { - return totals.metered + totals.unknown; -} - -/** - * Coerce a possibly-null/legacy/garbage value (e.g. a DB read from a row - * written before this column existed, or a relay payload from an older build) - * to a valid BillingMode. Unrecognized → "unknown". - * @param {unknown} value - * @returns {string} - */ -function normalizeBillingMode(value) { - return typeof value === "string" && BILLING_MODES.includes(value) - ? value - : "unknown"; -} - -/** Non-empty string presence check for an env var (existence only — never logged). */ -function hasNonEmptyEnv(env, key) { - const v = env && typeof env === "object" ? env[key] : undefined; - return typeof v === "string" && v.trim().length > 0; -} - -/** - * Resolve the Codex home dir, honoring the documented $CODEX_HOME override - * (same precedence the codex importer's codex-home.js uses) so a relocated - * Codex install is classified correctly rather than falling through to unknown. - */ -function codexHomeDir(deps) { - if (hasNonEmptyEnv(deps.env, "CODEX_HOME")) { - return deps.env.CODEX_HOME; - } - return path.join(deps.homeDir, ".codex"); -} - -/** - * Anthropic (Claude Code harness): an ANTHROPIC_API_KEY means real metered API - * billing; otherwise a present OAuth credential file means a Pro/Max - * subscription (tier undeterminable here → subscription_unknown). Neither path - * reads the secret's contents. - */ -function detectAnthropicBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "ANTHROPIC_API_KEY")) return "api"; - if (deps.fileExists(path.join(deps.homeDir, ".claude", ".credentials.json"))) { - return "subscription_unknown"; - } - return "unknown"; -} - -/** - * OpenAI/Codex harness: an OPENAI_API_KEY means metered API billing; otherwise - * a present Codex OAuth file means a ChatGPT/Codex subscription. - */ -function detectOpenAiBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "OPENAI_API_KEY")) return "api"; - if (deps.fileExists(path.join(codexHomeDir(deps), "auth.json"))) { - return "codex_subscription"; - } - return "unknown"; -} - -/** - * Cursor harness: a CURSOR_API_KEY means metered API billing; otherwise a - * tracked Cursor session (the importer only runs when transcripts exist) is a - * Pro/Business seat. Seat-share allocation math is out of scope (PRD-414). - */ -function detectCursorBillingMode(deps) { - if (hasNonEmptyEnv(deps.env, "CURSOR_API_KEY")) return "cursor_api"; - return "cursor_pro"; -} - -/** GitHub Copilot is always a seat-based subscription (no per-token API). */ -function detectCopilotBillingMode(_deps) { - return "copilot_seat"; -} - -/** OpenCode is bring-your-own-key; per-call billing attribution is deferred. */ -function detectOpencodeBillingMode(_deps) { - return "opencode"; -} - -/** - * Detect the billing mode for a harness from injected deps. Unknown harnesses - * resolve to "unknown" (ledger: unknown) rather than guessing. - * @param {string} harness one of "claude" | "codex" | "cursor" | "copilot" | "opencode" - * @param {{ env: object, fileExists: (p: string) => boolean, homeDir: string }} deps - * @returns {string} a BillingMode - */ -function detectBillingModeForHarness(harness, deps) { - switch (harness) { - case "claude": - return detectAnthropicBillingMode(deps); - case "codex": - return detectOpenAiBillingMode(deps); - case "cursor": - return detectCursorBillingMode(deps); - case "copilot": - return detectCopilotBillingMode(deps); - case "opencode": - return detectOpencodeBillingMode(deps); - default: - return "unknown"; - } -} - -module.exports = { - BILLING_MODES, - billingLedger, - isMeteredApi, - isSubscription, - emptyLedgerTotals, - addLedgerCost, - headlineCost, - normalizeBillingMode, - detectBillingModeForHarness, - // Exported for the parity test + targeted unit coverage. - detectAnthropicBillingMode, - detectOpenAiBillingMode, - detectCursorBillingMode, - detectCopilotBillingMode, - detectOpencodeBillingMode, -}; diff --git a/apps/desktop/scripts/agent-monitor-billing/package.json b/apps/desktop/scripts/agent-monitor-billing/package.json deleted file mode 100644 index bad511e4..00000000 --- a/apps/desktop/scripts/agent-monitor-billing/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "//": "Scopes this dir to CommonJS (parent apps/desktop is type:module). billing-mode.js is build-time-copied into the generated agent-monitor server/lib (a CommonJS tree), mirroring scripts/agent-monitor-cost. Not part of the desktop ESM build.", - "type": "commonjs", - "private": true -} diff --git a/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx b/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx deleted file mode 100644 index 579b3b02..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Dashboard.tsx +++ /dev/null @@ -1,2466 +0,0 @@ -/** - * @file Dashboard.tsx - * @description ClosedLoop-authored override that merges the upstream Analytics - * page into the Monitor tab. Copied verbatim over - * `src/pages/Dashboard.tsx` at build time by scripts/build-agent-monitor.mjs - * via CLIENT_FULL_FILE_OVERRIDES. - * - * Layout (Monitor tab): - * 1. Five stat pills (Sessions / Agents / Tokens / Cost / Events) - * 2. Active Agents section (full width) - * 3. Event Activity heatmap + Last 30 Days sparkline - * 4. Inner tabs: Cost / Tokens / Productivity / Workflow - * - * Health tab carries the upstream SystemHealthTab unchanged. - * - * Helpers from the upstream Analytics page (ChartTooltip, useTooltip, Heatmap, - * Sparkline, CostTrendLine, BarRow, CostBarRow, DonutChart, StatPill) are - * duplicated locally here rather than imported — Analytics.tsx does not export - * them, and inlining keeps the override a single self-contained file. - */ - -import { - useEffect, - useState, - useCallback, - useSyncExternalStore, - useMemo, - useRef, -} from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - LayoutDashboard, - FolderOpen, - Bot, - Zap, - DollarSign, - Activity, - ArrowRight, - RefreshCw, - GitBranch, - ChevronDown, - ChevronRight, - Server, - HardDrive, - Plug, - Cpu, - BarChart3, - ShieldCheck, - Database, - Search, - Clock, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { AgentCard } from "../components/AgentCard"; -import { EmptyState } from "../components/EmptyState"; -import { Tip } from "../components/Tip"; -import { fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; -import { loadLedgerPrefs, type CostByLedger } from "../lib/closedloop-ledger"; -import type { - Stats, - Agent, - WSMessage, - WorkflowData, - Analytics as AnalyticsData, - CostResult, -} from "../lib/types"; - -// ─── Analytics chart tooltip ────────────────────────────────────────────────── - -function ChartTooltip({ - x, - y, - children, -}: { - x: number; - y: number; - children: React.ReactNode; -}) { - const nearRight = x > window.innerWidth - 200; - return ( -
- {children} -
- ); -} - -function useTooltip() { - const [tooltip, setTooltip] = useState<{ - x: number; - y: number; - content: React.ReactNode; - } | null>(null); - - const show = (e: React.MouseEvent, content: React.ReactNode) => { - setTooltip({ x: e.clientX, y: e.clientY, content }); - }; - const move = (e: React.MouseEvent) => { - setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY }); - }; - const hide = () => setTooltip(null); - - const node = tooltip ? ( - - {tooltip.content} - - ) : null; - - return { show, move, hide, node }; -} - -// ─── Heatmap ────────────────────────────────────────────────────────────────── - -function cellColor(count: number, max: number) { - if (count === 0) return "#161625"; - const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1); - type RGB = [number, number, number]; - const stops: RGB[] = [ - [22, 20, 60], - [55, 48, 163], - [99, 102, 241], - [199, 210, 254], - ]; - const scaled = t * (stops.length - 1); - const lo = Math.min(Math.floor(scaled), stops.length - 2); - const frac = scaled - lo; - const [r1, g1, b1]: RGB = stops[lo] as RGB; - const [r2, g2, b2]: RGB = stops[lo + 1] as RGB; - const r = Math.round(r1 + (r2 - r1) * frac); - const g = Math.round(g1 + (g2 - g1) * frac); - const b = Math.round(b1 + (b2 - b1) * frac); - return `rgb(${r},${g},${b})`; -} - -function Heatmap({ - weeks, - locale, -}: { - weeks: Array>; - locale: string; -}) { - const { show, move, hide, node } = useTooltip(); - - const monthLabels = useMemo( - () => - Array.from({ length: 12 }, (_, month) => - new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1)) - ), - [locale] - ); - - const dayNames = useMemo( - () => - Array.from({ length: 7 }, (_, day) => - new Intl.DateTimeFormat(locale, { weekday: "short" }).format( - new Date(2026, 0, 4 + day) - ) - ), - [locale] - ); - - const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""]; - const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1); - - const monthPositions = useMemo(() => { - const positions: Array<{ label: string; col: number }> = []; - let prevMonth = -1; - weeks.forEach((week, wi) => { - const firstCell = week[0]; - if (!firstCell) return; - const parts = firstCell.date.split("-").map(Number); - const m = (parts[1] || 1) - 1; - if (m !== prevMonth) { - positions.push({ label: monthLabels[m] ?? "", col: wi }); - prevMonth = m; - } - }); - return positions; - }, [weeks, monthLabels]); - - return ( -
- {node} -
- {monthPositions.map((mp, i) => ( -
- {mp.label} -
- ))} -
-
-
- {dayLabels.map((d, i) => ( -
- {d} -
- ))} -
- {weeks.map((week, wi) => ( -
- {week.map((cell) => ( -
{ - const parts = cell.date.split("-").map(Number); - const y = parts[0] || 0; - const m = (parts[1] || 1) - 1; - const d = parts[2] || 1; - const date = new Date(y, m, d, 12); - const dow = date.getDay(); - show( - e, - <> - - {dayNames[dow] ?? ""}, {cell.date} - - - {cell.count.toLocaleString()} events - - - ); - }} - onMouseMove={move} - onMouseLeave={hide} - style={{ - width: 13, - height: 13, - borderRadius: 2, - backgroundColor: cellColor(cell.count, maxCount), - border: "1px solid rgba(255,255,255,0.04)", - flexShrink: 0, - cursor: "default", - }} - /> - ))} -
- ))} -
-
- Less - {[0, 0.25, 0.5, 0.75, 1].map((f) => { - const v = Math.round(f * maxCount); - return ( -
- ); - })} - More -
-
- ); -} - -// ─── Sparkline + cost trend ─────────────────────────────────────────────────── - -function Sparkline({ - data, - color = "#6366f1", -}: { - data: Array<{ date: string; count: number }>; - color?: string; -}) { - const { show, move, hide, node } = useTooltip(); - const max = Math.max(...data.map((d) => d.count), 1); - return ( -
- {node} - {data.map(({ date, count }) => ( -
- show( - e, - <> - {date} - {count.toLocaleString()} events - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ))} -
- ); -} - -function CostTrendLine({ - data, - color = "#10b981", -}: { - data: Array<{ date: string; cost: number }>; - color?: string; -}) { - const { show, move, hide, node } = useTooltip(); - if (data.length === 0) return null; - - const width = 320; - const height = 88; - const padX = 8; - const padY = 8; - const min = Math.min(...data.map((d) => d.cost), 0); - const max = Math.max(...data.map((d) => d.cost), 0); - const span = Math.max(max - min, 0.0001); - const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0; - - const points = data.map(({ date, cost }, i) => { - const x = padX + i * step; - const y = height - padY - ((cost - min) / span) * (height - padY * 2); - return { date, cost, x, y }; - }); - - const linePoints = points.map((p) => `${p.x},${p.y}`).join(" "); - const firstX = points[0]?.x ?? padX; - const lastX = points[points.length - 1]?.x ?? padX; - const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`; - - return ( -
- {node} - - - - - - - - - - {points.map((point) => ( - - - - show( - e, - <> - {point.date} - {fmtCostFull(point.cost)} - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - - ))} - -
- ); -} - -// ─── Bar rows + donut ───────────────────────────────────────────────────────── - -function BarRow({ - label, - count, - max, - color = "bg-accent", - pct, -}: { - label: string; - count: number; - max: number; - color?: string; - pct?: number; -}) { - const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; - return ( -
- - {label} - -
-
-
- - {fmt(count)} - -
- ); -} - -function CostBarRow({ - label, - cost, - max, - color = "bg-emerald-400", -}: { - label: string; - cost: number; - max: number; - color?: string; -}) { - const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; - return ( -
- - {label} - -
-
-
- - {fmtCost(cost)} - -
- ); -} - -function DonutChart({ - segments, - formatTotal, -}: { - segments: Array<{ label: string; value: number; color: string }>; - formatTotal?: (total: number) => string; -}) { - const { show, move, hide, node } = useTooltip(); - const total = segments.reduce((s, g) => s + g.value, 0); - if (total === 0) return
No data
; - - const r = 52; - const cx = 64; - const cy = 64; - const stroke = 18; - const circumference = 2 * Math.PI * r; - - let offset = circumference / 4; - return ( -
- {node} - - - {segments.map(({ label, value, color }, i) => { - const dash = (value / total) * circumference; - const gap = circumference - dash; - const pct = Math.round((value / total) * 100); - const currentOffset = offset; - offset -= dash; - return ( - - show( - e, - <> - {label} - {pct}% - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ); - })} - - {(formatTotal ?? fmt)(total)} - - - total - - -
- {segments.map(({ label, value, color }) => ( -
- - {label} - - {Math.round((value / total) * 100)}% - -
- ))} -
-
- ); -} - -// ─── StatPill ───────────────────────────────────────────────────────────────── - -function StatPill({ - label, - value, - raw, - sub, - icon: Icon, - color = "text-accent", - testid, - subTestid, -}: { - label: string; - value: string | number; - raw?: string; - sub?: string; - icon: React.ElementType; - color?: string; - testid?: string; - subTestid?: string; -}) { - return ( -
-
- {label} - -
-

- {raw ? {value} : value} -

- {sub && ( -

- {sub} -

- )} -
- ); -} - -// ─── SystemHealthTab (preserved verbatim from upstream Dashboard) ───────────── - -interface SystemInfo { - db: { - path: string; - size: number; - counts: Record; - pragmas: { - journal_mode: string; - synchronous: number; - auto_vacuum: number; - encoding: string; - foreign_keys: number; - busy_timeout: number; - }; - load_stats: { m5: number; m15: number; h1: number }; - }; - hooks: { installed: boolean; path: string; hooks: Record }; - server: { - uptime: number; - node_version: string; - platform: string; - ws_connections: number; - memory: { rss: number; heapTotal: number; heapUsed: number; external: number }; - cpu_load: number[]; - arch: string; - total_mem: number; - free_mem: number; - cpus: number; - }; - transcript_cache: { - size: number; - maxSize: number; - hits: number; - misses: number; - keys: string[]; - }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function SystemHealthTab() { - const [info, setInfo] = useState(null); - const [workflow, setWorkflow] = useState(null); - - const loadData = useCallback(async () => { - try { - const [infoRes, workflowRes] = await Promise.all([ - api.settings.info(), - api.workflows.get(), - ]); - setInfo(infoRes as any); - setWorkflow(workflowRes); - } catch (e) { - console.error(e); - } - }, []); - - useEffect(() => { - loadData(); - const int = setInterval(loadData, 30000); - return () => clearInterval(int); - }, [loadData]); - - const stats = useMemo(() => { - if (!info || !workflow) return null; - - const totalEntries = - (info.db.counts?.sessions || 0) + - (info.db.counts?.agents || 0) + - (info.db.counts?.events || 0); - const sessPct = totalEntries > 0 ? ((info.db.counts?.sessions || 0) / totalEntries) * 100 : 0; - const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0; - const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0; - - const modelStats = (workflow.modelDelegation?.tokensByModel || []) - .sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens)) - .slice(0, 6); - const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0); - - const memUsedPct = - info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0; - const heapUsedPct = - info.server.memory.heapTotal > 0 - ? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100 - : 0; - - return { - totalEntries, - sessPct, - agentPct, - eventPct, - modelStats, - totalTokens, - memUsedPct, - heapUsedPct, - }; - }, [info, workflow]); - - if (!info || !workflow || !stats) { - return ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
- ))} -
- ); - } - - const { - totalEntries, - sessPct, - agentPct, - eventPct, - modelStats, - totalTokens, - memUsedPct, - heapUsedPct, - } = stats; - const successRate = Math.max(0, Math.min(100, workflow.stats.successRate)); - const errorRate = Math.max( - 0, - Math.min(100, workflow.errorPropagation?.errorRate ?? 100 - successRate) - ); - const cacheHitRate = Math.max( - 0, - Math.min( - 100, - ((info.transcript_cache?.hits ?? 0) / - ((info.transcript_cache?.hits ?? 0) + (info.transcript_cache?.misses ?? 0) || 1)) * - 100 - ) - ); - - const lanes = workflow.concurrency?.aggregateLanes || []; - const maxLaneCount = Math.max(...lanes.map((l) => l.count), 1); - - const topTools = (workflow.toolFlow?.toolCounts || []).slice(0, 8); - const maxToolCount = topTools.length > 0 ? (topTools[0]?.count ?? 1) : 1; - - const effectiveness = (workflow.effectiveness || []).slice(0, 6); - - const healthScore = Math.max( - 0, - Math.min( - 100, - successRate * 0.4 + - cacheHitRate * 0.25 + - Math.max(0, 100 - errorRate) * 0.25 + - Math.max(0, 100 - Math.min(100, heapUsedPct)) * 0.1 - ) - ); - - return ( -
-
-
-
-
- - Runtime -
- - {info.server.cpus} cores · {info.server.arch} - -
- -
-
- Uptime - - {formatUptime(info.server.uptime)} - -
-
- CPU (1/5/15m) -
- {(info.server.cpu_load || []).slice(0, 3).map((load, i) => ( - info.server.cpus ? "bg-red-500/20 text-red-400" : "bg-surface-3 text-gray-300"}`} - > - {load.toFixed(2)} - - ))} -
-
-
- Node RSS - - {formatBytes(info.server.memory.rss)} - -
-
- -
- -
-
- Host Memory - {memUsedPct.toFixed(0)}% -
-
-
90 ? "bg-red-500" : memUsedPct > 70 ? "bg-amber-500" : "bg-emerald-500"}`} - style={{ width: `${memUsedPct}%` }} - /> -
-
- - -
-
- V8 Heap - {heapUsedPct.toFixed(0)}% -
-
-
85 ? "bg-red-500" : heapUsedPct > 60 ? "bg-amber-500" : "bg-blue-500"}`} - style={{ width: `${heapUsedPct}%` }} - /> -
-
- -
-
- -
-
-
- - Storage -
- - - ⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/ - {info.db.load_stats?.h1 ?? 0} - - -
- -
- Database - - {formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"} - -
- -
- - - - {(() => { - const r = 38, - cx = 48, - cy = 48, - circumference = 2 * Math.PI * r; - const segments = [ - { pct: sessPct, color: "#60a5fa" }, - { pct: agentPct, color: "#8b5cf6" }, - { pct: eventPct, color: "#34d399" }, - ]; - let offset = circumference / 4; - return segments.map((seg, i) => { - if (seg.pct <= 0) return null; - const dash = (seg.pct / 100) * circumference; - const gap = circumference - dash; - const currentOffset = offset; - offset -= dash; - return ( - - ); - }); - })()} - - {totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries} - - - total - - - -
- {[ - { - label: "Sessions", - value: info.db.counts?.sessions ?? 0, - color: "#60a5fa", - pct: sessPct, - }, - { - label: "Agents", - value: info.db.counts?.agents ?? 0, - color: "#8b5cf6", - pct: agentPct, - }, - { - label: "Events", - value: info.db.counts?.events ?? 0, - color: "#34d399", - pct: eventPct, - }, - ].map((item) => ( - -
- - {item.label} - - {Math.round(item.pct)}% - -
-
- ))} -
-
-
- -
-
-
- - Health Score -
- - - ⓘ Formula - - -
- -
- - - - = 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"} - strokeWidth="10" - strokeLinecap="round" - strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`} - strokeDashoffset={301.6 / 4} - className="transition-all duration-1000" - /> - - {healthScore.toFixed(0)} - - - / 100 - - - -
- -
- -
-

Cache

-

- {cacheHitRate.toFixed(0)}% -

-
-
- 15% = critical`} - > -
-

Errors

-

- {errorRate.toFixed(1)}% -

-
-
- -
-

Compact

-

- {workflow.compaction?.totalCompactions ?? 0} -

-
-
- -
-

Saved

-

- {((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K -

-
-
-
-
-
- -
-
-
-
- - Token Usage -
- - {(totalTokens / 1000).toFixed(1)}K total - -
- -
- {modelStats.map((m, i) => { - const pct = - totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0; - const colors = [ - "bg-blue-400", - "bg-violet-400", - "bg-emerald-400", - "bg-amber-400", - "bg-pink-400", - "bg-cyan-400", - ]; - return ( - -
- - {formatModelName(m.model) ?? m.model} - -
-
-
- - {pct.toFixed(1)}% - -
- - ); - })} - {modelStats.length === 0 && ( -

No model data

- )} -
-
- -
-
-
- - Concurrency -
- {lanes.length} intervals -
- - l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`} - > -
- {lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => { - const barPct = - maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4; - const color = - lane.count > 5 - ? "#f87171" - : lane.count > 2 - ? "#fbbf24" - : lane.count > 0 - ? "#34d399" - : "#1e1e2e"; - return ( -
- ); - })} - {lanes.length === 0 && ( -

- No concurrency data -

- )} -
- - -
- -
-

Peak

-

{maxLaneCount}

-
-
- l.count > 0).length} of ${lanes.length} intervals have active sessions.`} - > -
-

Active

-

- {lanes.filter((l) => l.count > 0).length} -

-
-
- -
-

Avg

-

- {lanes.length > 0 - ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) - : "0"} -

-
-
-
-
-
- -
-
-
-
- - Tool Usage -
- top {topTools.length} -
- -
- {topTools.map((tool, i) => { - const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0; - const colors = [ - "bg-amber-400", - "bg-blue-400", - "bg-emerald-400", - "bg-violet-400", - "bg-pink-400", - "bg-cyan-400", - "bg-red-400", - "bg-indigo-400", - ]; - return ( - -
- - {tool.tool_name} - -
-
-
- - {tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count} - -
- - ); - })} - {topTools.length === 0 && ( -

No tool data yet

- )} -
-
- -
-
-
- - - Subagent Effectiveness - -
-
- -
- {effectiveness.map((item, i) => { - const color = - item.successRate >= 90 - ? "bg-emerald-400" - : item.successRate >= 70 - ? "bg-amber-400" - : "bg-red-400"; - return ( - -
- - {item.subagent_type || "default"} - -
-
-
- = 90 ? "text-emerald-400" : item.successRate >= 70 ? "text-amber-400" : "text-red-400"}`} - > - {item.successRate.toFixed(0)}% - -
- - ); - })} - {effectiveness.length === 0 && ( -

No subagent data yet

- )} -
-
-
- -
-
-
-
- - Integration -
- - {info.hooks.installed ? "Active" : "Offline"} - -
- - {Object.entries(info.hooks.hooks || {}).length > 0 ? ( -
- {Object.entries(info.hooks.hooks).map(([cwd, active]) => ( - -
-
- - {cwd.split("/").pop() || cwd} - -
- - ))} -
- ) : ( -
- -

No project hooks registered

-
- )} - - -
- -
-

WebSocket Active

-

- {info.server.ws_connections} connection - {info.server.ws_connections !== 1 ? "s" : ""} -

-
-
-
-
- -
-
-
- - Platform -
- {info.server.node_version} -
- -
- {[ - { - label: "Journal Mode", - value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL", - }, - { - label: "Synchronous", - value: - info.db.pragmas?.synchronous === 2 - ? "FULL" - : info.db.pragmas?.synchronous === 1 - ? "NORMAL" - : "OFF", - }, - { label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" }, - { label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" }, - { label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` }, - { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))} -
- - -
- - {info.db.path} -
-
-
-
-
- ); -} - -// ─── Main Dashboard ─────────────────────────────────────────────────────────── - -function localDateStr(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -export function Dashboard() { - const navigate = useNavigate(); - const { t, i18n } = useTranslation("dashboard"); - const locale = i18n.resolvedLanguage ?? i18n.language; - - const [activeTab, setActiveTab] = useState<"monitor" | "health">(() => { - return (localStorage.getItem("dashboard_tab") as "monitor" | "health") || "monitor"; - }); - - useEffect(() => { - localStorage.setItem("dashboard_tab", activeTab); - }, [activeTab]); - - const [analyticsTab, setAnalyticsTab] = useState< - "cost" | "tokens" | "productivity" | "workflow" - >("cost"); - - // Active-agents + headline stats (carried over from upstream Dashboard). - const [stats, setStats] = useState(null); - const [activeAgents, setActiveAgents] = useState([]); - const [allSubagents, setAllSubagents] = useState([]); - const [expandedAgents, setExpandedAgents] = useState>(new Set()); - const [error, setError] = useState(null); - - // Analytics data (merged in from the upstream Analytics page). - const [analyticsData, setAnalyticsData] = useState(null); - const [costData, setCostData] = useState(null); - // CLOSEDLOOP FEA-1434: opt-in display of the subscription "would have cost". - // Read once on mount from the localStorage pref written in Settings; the - // Settings/Dashboard routes unmount on navigation, so returning here re-reads - // the latest value. Default off — subscription cost is hypothetical and is - // never part of the billed headline regardless of this flag. - const [showHypotheticalCost] = useState(() => loadLedgerPrefs().showHypotheticalCost); - - const agentsContainerRef = useRef(null); - const [visibleAgentCount, setVisibleAgentCount] = useState(5); - - useEffect(() => { - const AGENT_ROW_H = 56; - const HEADER_H = 40; - - function recalc() { - if (agentsContainerRef.current) { - const h = agentsContainerRef.current.clientHeight; - setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H))); - } - } - - const ro = new ResizeObserver(recalc); - if (agentsContainerRef.current) ro.observe(agentsContainerRef.current); - recalc(); - - return () => ro.disconnect(); - }, [activeTab]); - - const load = useCallback(async () => { - try { - const [statsRes, workingRes, waitingRes, costRes, analyticsRes] = await Promise.all([ - api.stats.get(), - api.agents.list({ status: "working", limit: 20 }), - api.agents.list({ status: "waiting", limit: 20 }), - api.pricing.totalCost().catch(() => null), - api.analytics.get().catch(() => null), - ]); - setStats(statsRes); - const active = [...workingRes.agents, ...waitingRes.agents]; - setActiveAgents(active); - setCostData(costRes); - setAnalyticsData(analyticsRes); - setError(null); - - const activeSessionIds = [ - ...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)), - ]; - const subagentResults = await Promise.all( - activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 })) - ); - const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent"); - setAllSubagents(subs); - } catch (err) { - setError(err instanceof Error ? err.message : t("failedLoad")); - } - }, [t]); - - useEffect(() => { - load(); - const interval = setInterval(load, 10000); - return () => clearInterval(interval); - }, [load]); - - useEffect(() => { - const parentsWithActive = new Set(); - for (const a of allSubagents) { - if (a.parent_agent_id && a.status === "working") { - parentsWithActive.add(a.parent_agent_id); - } - } - if (parentsWithActive.size === 0) return; - - const subMap = new Map(allSubagents.map((a) => [a.id, a])); - const toExpand = new Set(); - for (const pid of parentsWithActive) { - let cur = pid; - while (cur) { - toExpand.add(cur); - const parent = subMap.get(cur); - cur = parent?.parent_agent_id ?? ""; - } - } - setExpandedAgents((prev) => { - const newIds = [...toExpand].filter((id) => !prev.has(id)); - if (newIds.length === 0) return prev; - return new Set([...prev, ...newIds]); - }); - }, [allSubagents]); - - useEffect(() => { - const debounceRef = { timer: null as ReturnType | null }; - return eventBus.subscribe((msg: WSMessage) => { - if ( - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "session_created" || - msg.type === "session_updated" || - msg.type === "new_event" - ) { - if (debounceRef.timer) clearTimeout(debounceRef.timer); - debounceRef.timer = setTimeout(load, 300); - } - }); - }, [load]); - - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - - const agentTree = useMemo(() => { - const childrenByParent = new Map(); - for (const a of allSubagents) { - if (a.parent_agent_id) { - const list = childrenByParent.get(a.parent_agent_id) || []; - list.push(a); - childrenByParent.set(a.parent_agent_id, list); - } - } - - const descendantCache = new Map(); - function getDescendants(id: string): { total: number; active: number } { - if (descendantCache.has(id)) return descendantCache.get(id)!; - const kids = childrenByParent.get(id) || []; - const result = kids.reduce( - (acc, k) => { - const child = getDescendants(k.id); - return { - total: acc.total + 1 + child.total, - active: acc.active + (k.status === "working" ? 1 : 0) + child.active, - }; - }, - { total: 0, active: 0 } - ); - descendantCache.set(id, result); - return result; - } - for (const a of allSubagents) getDescendants(a.id); - - return { childrenByParent, getDescendants }; - }, [allSubagents]); - - // ── Analytics-derived data ──────────────────────────────────────────────── - - const dailyMap = useMemo(() => { - const m: Record = {}; - for (const d of analyticsData?.daily_events ?? []) { - m[d.date] = (m[d.date] ?? 0) + d.count; - } - return m; - }, [analyticsData]); - - const today = useMemo(() => { - const d = new Date(); - d.setHours(12, 0, 0, 0); - return d; - }, []); - - const weeks = useMemo(() => { - const startDate = new Date(today); - startDate.setDate(today.getDate() - 364); - const startDow = startDate.getDay(); - startDate.setDate(startDate.getDate() - startDow); - - const result: Array> = []; - for (let w = 0; w < 53; w++) { - const week: Array<{ date: string; count: number }> = []; - for (let d = 0; d < 7; d++) { - const cell = new Date(startDate); - cell.setDate(startDate.getDate() + w * 7 + d); - if (cell > today) break; - const dateStr = localDateStr(cell); - week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 }); - } - if (week.length > 0) result.push(week); - } - return result; - }, [today, dailyMap]); - - const last30 = useMemo( - () => - Array.from({ length: 30 }, (_, i) => { - const d = new Date(today); - d.setDate(today.getDate() - (29 - i)); - const dateStr = localDateStr(d); - return { date: dateStr, count: dailyMap[dateStr] ?? 0 }; - }), - [today, dailyMap] - ); - - const dailySessionsLocal = useMemo(() => { - const result: Array<{ date: string; count: number }> = []; - const sessMap: Record = {}; - for (const d of analyticsData?.daily_sessions ?? []) { - sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count; - } - for (const [date, count] of Object.entries(sessMap)) { - result.push({ date, count }); - } - result.sort((a, b) => a.date.localeCompare(b.date)); - return result; - }, [analyticsData]); - - const costMap = useMemo(() => { - const m: Record = {}; - for (const d of costData?.daily_costs ?? []) { - m[d.date] = (m[d.date] ?? 0) + d.cost; - } - return m; - }, [costData]); - - const dailyCostLast30 = useMemo( - () => - Array.from({ length: 30 }, (_, i) => { - const d = new Date(today); - d.setDate(today.getDate() - (29 - i)); - const dateStr = localDateStr(d); - return { date: dateStr, cost: costMap[dateStr] ?? 0 }; - }), - [today, costMap] - ); - - const peakCostDay = dailyCostLast30.reduce( - (max, curr) => (curr.cost > max.cost ? curr : max), - dailyCostLast30[0] ?? { date: "", cost: 0 } - ); - const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0); - const costBreakdown = [...(costData?.breakdown ?? [])] - .filter((b) => b.cost > 0) - .sort((a, b) => b.cost - a.cost); - - // CLOSEDLOOP FEA-1434 two-ledger headline. `costData.total_cost` is already - // the billed figure (metered + unknown) — the server excludes - // subscription-covered spend — so the Total Cost pill value stays the honest - // billed number. `cost_by_ledger.subscription` is the hypothetical "would - // have cost" of subscription sessions; it is surfaced in the pill subtitle - // ONLY when the user opts in via Settings, and is never added to the headline. - // The upstream CostResult type predates cost_by_ledger, so read it via cast. - const costByLedger = (costData as (CostResult & { cost_by_ledger?: CostByLedger }) | null) - ?.cost_by_ledger; - const subscriptionCost = costByLedger?.subscription ?? 0; - const modelCountSub = costData - ? `${costData.breakdown.length} model${costData.breakdown.length === 1 ? "" : "s"}` - : "No cost data yet"; - const costPillSub = - showHypotheticalCost && subscriptionCost > 0 - ? `${modelCountSub} · +${fmtCost(subscriptionCost)} subscription-covered` - : modelCountSub; - - const weekdayCosts = useMemo(() => { - const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; - return weekdayOrder.map((dow) => { - const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( - new Date(Date.UTC(2026, 0, 4 + dow)) - ); - const cost = dailyCostLast30 - .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow) - .reduce((sum, day) => sum + day.cost, 0); - return { label, cost }; - }); - }, [locale, dailyCostLast30]); - const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); - - const totalTokens = - (analyticsData?.tokens.total_input ?? 0) + - (analyticsData?.tokens.total_output ?? 0) + - (analyticsData?.tokens.total_cache_read ?? 0) + - (analyticsData?.tokens.total_cache_write ?? 0); - - const tokenMixSegments = [ - { label: "Input", value: analyticsData?.tokens.total_input ?? 0, color: "#60a5fa" }, - { label: "Output", value: analyticsData?.tokens.total_output ?? 0, color: "#34d399" }, - { label: "Cache Read", value: analyticsData?.tokens.total_cache_read ?? 0, color: "#a78bfa" }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "#facc15", - }, - ].filter((s) => s.value > 0); - - const maxToolCount = analyticsData?.tool_usage[0]?.count ?? 1; - const maxAgentTypeCount = analyticsData?.agent_types[0]?.count ?? 1; - const maxEventTypeCount = analyticsData?.event_types[0]?.count ?? 1; - - const cacheHitPct = - totalTokens > 0 - ? Math.round(((analyticsData?.tokens.total_cache_read ?? 0) / totalTokens) * 100) - : 0; - - const sessionOutcomeSegments = [ - { label: "Completed", value: analyticsData?.sessions_by_status?.completed ?? 0, color: "#8b5cf6" }, - { label: "Active", value: analyticsData?.sessions_by_status?.active ?? 0, color: "#10b981" }, - { label: "Error", value: analyticsData?.sessions_by_status?.error ?? 0, color: "#ef4444" }, - { - label: "Abandoned", - value: analyticsData?.sessions_by_status?.abandoned ?? 0, - color: "#f59e0b", - }, - ].filter((s) => s.value > 0); - - const agentStatusSegments = [ - { label: "Completed", value: analyticsData?.agents_by_status?.completed ?? 0, color: "#8b5cf6" }, - { label: "Working", value: analyticsData?.agents_by_status?.working ?? 0, color: "#10b981" }, - { label: "Waiting", value: analyticsData?.agents_by_status?.waiting ?? 0, color: "#eab308" }, - { label: "Error", value: analyticsData?.agents_by_status?.error ?? 0, color: "#ef4444" }, - ].filter((s) => s.value > 0); - - const EVENT_TYPE_COLORS: Record = { - PreToolUse: "bg-emerald-400", - PostToolUse: "bg-blue-400", - Stop: "bg-violet-400", - SubagentStop: "bg-yellow-400", - Notification: "bg-orange-400", - }; - - if (error) { - return ( -
-

{t("failedConnect")}

-

{error}

- -
- ); - } - - return ( -
-
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

{t("subtitle")}

-
-
-
-
- - -
- -
-
- - {activeTab === "monitor" ? ( -
- {/* 5 stat pills */} -
- - - - - -
- - {/* Active Agents — full width, directly under the stat pills */} -
-
-

{t("activeAgentsSection")}

- -
- {activeAgents.length === 0 ? ( - - ) : ( -
- {(() => { - const { childrenByParent, getDescendants } = agentTree; - - function renderAgentNode(agent: Agent, depth: number) { - const children = childrenByParent.get(agent.id) || []; - const isExpanded = expandedAgents.has(agent.id); - const hasChildren = children.length > 0; - const isSubagent = depth > 0; - const { total: totalDesc, active: activeDesc } = hasChildren - ? getDescendants(agent.id) - : { total: 0, active: 0 }; - const toggleExpanded = () => - setExpandedAgents((prev) => { - const next = new Set(prev); - if (next.has(agent.id)) next.delete(agent.id); - else next.add(agent.id); - return next; - }); - - return ( -
-
- {hasChildren && ( - - )} - {!hasChildren && } - {isSubagent && ( - - )} -
- -
-
- - {hasChildren && isExpanded && ( -
- {children.map((child) => renderAgentNode(child, depth + 1))} -
- )} - - {hasChildren && !isExpanded && ( - - )} -
- ); - } - - return ( - <> - {activeAgents - .filter((a) => a.type === "main") - .slice(0, visibleAgentCount) - .map((main) => renderAgentNode(main, 0))} - {activeAgents - .filter((a) => a.type === "subagent") - .map((agent) => ( -
- -
- ))} - - ); - })()} -
- )} -
- - {/* Event activity heatmap + Last 30 days sparkline */} -
-
-

Event Activity

-

Last 52 weeks · daily event counts

-
-
- -
-
-
-
-

Last 30 Days

-

Daily event count

- -
- {last30[0]?.date?.slice(5)} - {last30[last30.length - 1]?.date?.slice(5)} -
-
-
- Peak day - - d.count)).toLocaleString()}> - {fmt(Math.max(...last30.map((d) => d.count)))} - {" "} - events - -
-
- Total (30d) - - s + d.count, 0).toLocaleString()}> - {fmt(last30.reduce((s, d) => s + d.count, 0))} - {" "} - events - -
-
-
-
- - {/* Inner analytics tabs */} -
-
- {( - [ - { key: "cost" as const, label: "Cost Analytics" }, - { key: "tokens" as const, label: "Token Analytics" }, - { key: "productivity" as const, label: "Productivity Analytics" }, - { key: "workflow" as const, label: "Workflow Intelligence" }, - ] as const - ).map(({ key, label }) => ( - - ))} -
- - {analyticsTab === "cost" && ( -
-
-

Daily Cost Trends

- {Object.keys(costMap).length === 0 ? ( -

No daily cost data yet

- ) : ( - <> -

Cost per day

- -
- {dailyCostLast30[0]?.date?.slice(5)} - - {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)} - -
-
-
- Peak cost day - - - {fmtCost(peakCostDay.cost)} - - -
-
- Total (30d) - - {fmtCost(totalCost30d)} - -
-
- - )} -
- -
-

Cost by Model

- {costBreakdown.length > 0 ? ( - <> - ({ - label: formatModelName(b.model) ?? b.model, - value: Math.round(b.cost * 100), - color: - ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][ - i % 6 - ] ?? "#6b7280", - }))} - formatTotal={(cents) => fmtCost(cents / 100)} - /> -
- {costBreakdown.map((b) => ( -
- - {formatModelName(b.model)} - - - {fmtCost(b.cost)} - -
- ))} -
- Total - - - {fmtCost(costData?.total_cost ?? 0)} - - -
-
- - ) : ( -

No cost data yet

- )} -
- -
-

Cost by Weekday

- {Object.keys(costMap).length === 0 ? ( -

No daily cost data yet

- ) : ( - <> -

Last 30 days

-
- {weekdayCosts.map(({ label, cost }) => ( - - ))} -
-
- Total - - {fmtCost(totalCost30d)} - -
- - )} -
-
- )} - - {analyticsTab === "tokens" && ( -
-
-

Token Distribution

-
- {[ - { - label: "Input", - value: analyticsData?.tokens.total_input ?? 0, - color: "bg-blue-400", - }, - { - label: "Output", - value: analyticsData?.tokens.total_output ?? 0, - color: "bg-emerald-400", - }, - { - label: "Cache Read", - value: analyticsData?.tokens.total_cache_read ?? 0, - color: "bg-violet-400", - }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "bg-yellow-400", - }, - ].map(({ label, value, color }) => ( - - ))} -
-
-
- Total tokens - - {fmt(totalTokens)} - -
-
- Cache efficiency - {cacheHitPct}% -
-
-
- -
-

Token Breakdown

-
- {[ - { - label: "Input", - value: analyticsData?.tokens.total_input ?? 0, - color: "text-blue-400", - }, - { - label: "Output", - value: analyticsData?.tokens.total_output ?? 0, - color: "text-emerald-400", - }, - { - label: "Cache Read", - value: analyticsData?.tokens.total_cache_read ?? 0, - color: "text-violet-400", - }, - { - label: "Cache Write", - value: analyticsData?.tokens.total_cache_write ?? 0, - color: "text-yellow-400", - }, - { label: "Total", value: totalTokens, color: "text-gray-100" }, - ].map(({ label, value, color }) => ( -
- {label} - - {value.toLocaleString()} - -
- ))} -
- {totalTokens === 0 && ( -

- Token data will appear once sessions report usage. -

- )} -
- -
-

Token Mix

- {tokenMixSegments.length === 0 ? ( -

No data

- ) : ( - <> - fmt(total)} /> -
- {tokenMixSegments.map((segment) => ( -
- {segment.label} - - {fmt(segment.value)} - -
- ))} -
- - )} -
-
- )} - - {analyticsTab === "productivity" && ( -
-
-

Tool Usage

- {(analyticsData?.tool_usage ?? []).length === 0 ? ( -

No tool data yet

- ) : ( -
- {(analyticsData?.tool_usage ?? []) - .slice(0, 12) - .map(({ tool_name, count }) => ( - - ))} -
- )} -
- -
-

Session Outcomes

- -
-
- Total sessions - - - {fmt(analyticsData?.overview.total_sessions ?? 0)} - - -
- {sessionOutcomeSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- -
-

Daily Session Trends

- {dailySessionsLocal.length === 0 ? ( -

No session trend data

- ) : ( - <> - -
- {dailySessionsLocal - .slice(-7) - .reverse() - .map(({ date, count }) => { - const maxD = Math.max( - ...(dailySessionsLocal.length > 0 - ? dailySessionsLocal - : [{ count: 1 }] - ).map((d) => d.count) - ); - return ( -
- - {date.slice(5)} - -
-
-
- - {count} - -
- ); - })} -
-

Last 7 days

- - )} -
-
- )} - - {analyticsTab === "workflow" && ( -
-
-

Subagent Types

- {(analyticsData?.agent_types ?? []).length === 0 ? ( -

No subagent data yet

- ) : ( -
- {(analyticsData?.agent_types ?? []) - .slice(0, 10) - .map(({ subagent_type, count }) => ( - - ))} -
- )} -
- -
-

Agent Status

- -
-
- Total agents - - - {fmt(analyticsData?.overview.total_agents ?? 0)} - - -
- {agentStatusSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- -
-

Event Types

- {(analyticsData?.event_types ?? []).length === 0 ? ( -

No event data yet

- ) : ( -
- {(analyticsData?.event_types ?? []).map(({ event_type, count }) => ( - - ))} -
- )} -
-
- )} -
-
- ) : ( - - )} -
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/Sessions.tsx b/apps/desktop/scripts/agent-monitor-client/Sessions.tsx deleted file mode 100644 index 8ad81c10..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Sessions.tsx +++ /dev/null @@ -1,453 +0,0 @@ -/** - * @file Sessions.tsx - * @description Displays a list of all recorded sessions with filtering, - * searching, and pagination features. - */ - -import { useEffect, useState, useCallback, useSyncExternalStore } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - FolderOpen, - Search, - ChevronRight, - RefreshCw, - SortDesc, - SortAsc, - ChevronDown, - Play, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { SessionStatusBadge, HarnessBadge, BillingBadge } from "../components/StatusBadge"; -import { EmptyState } from "../components/EmptyState"; -import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format"; -import { effectiveSessionStatus, isSessionAwaitingInput } from "../lib/types"; -import type { Session, DashboardEvent } from "../lib/types"; - -const PAGE_SIZE = 10; -export function Sessions() { - const navigate = useNavigate(); - const { t } = useTranslation("sessions"); - const [sessions, setSessions] = useState([]); - const [total, setTotal] = useState(0); - const [filter, setFilter] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [search, setSearch] = useState(""); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(0); - - const [cwd, setCwd] = useState(""); - const [sortBy, setSortBy] = useState("time"); - const [sortDesc, setSortDesc] = useState(true); - const [directories, setDirectories] = useState([]); - const [dashboardRunIds, setDashboardRunIds] = useState>(new Set()); - const [harness, setHarness] = useState(""); - - const HARNESS_OPTIONS: Array<{ label: string; value: string }> = [ - { label: "All Harnesses", value: "" }, - { label: "Claude", value: "claude" }, - { label: "Codex", value: "codex" }, - { label: "Cursor", value: "cursor" }, - { label: "Copilot", value: "copilot" }, - { label: "OpenCode", value: "opencode" }, - ]; - - const FILTER_OPTIONS: Array<{ label: string; value: string }> = [ - { label: t("filterAll"), value: "" }, - { label: t("filterActive"), value: "active" }, - { label: t("filterWaiting"), value: "waiting" }, - { label: t("filterCompleted"), value: "completed" }, - { label: t("filterError"), value: "error" }, - { label: t("filterAbandoned"), value: "abandoned" }, - ]; - - useEffect(() => { - const id = window.setTimeout(() => setSearch(searchInput.trim()), 300); - return () => window.clearTimeout(id); - }, [searchInput]); - - useEffect(() => { - api.sessions - .facets() - .then((res) => { - setDirectories(res.cwds); - }) - .catch(console.error); - }, []); - - const load = useCallback(async () => { - try { - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", - q: search || undefined, - cwd: cwd || undefined, - harness: harness || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - limit: 10000, - offset: 0, - }); - let rows = res.sessions; - rows = rows.filter(isSessionAwaitingInput); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); - return; - } - const params: { - status?: string; - q?: string; - cwd?: string; - harness?: string; - sort_by?: string; - sort_desc?: boolean; - limit: number; - offset: number; - } = { - limit: PAGE_SIZE, - offset: page * PAGE_SIZE, - sort_by: sortBy, - sort_desc: sortDesc, - }; - if (filter) params.status = filter; - if (search) params.q = search; - if (cwd) params.cwd = cwd; - if (harness) params.harness = harness; - const res = await api.sessions.list(params); - setSessions(res.sessions); - setTotal(res.total); - } finally { - setLoading(false); - } - }, [filter, harness, search, cwd, sortBy, sortDesc, page]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [filter, harness, search, cwd, sortBy, sortDesc]); - - useEffect(() => { - return eventBus.subscribe((msg) => { - if (msg.type === "session_created" || msg.type === "session_updated") { - load(); - } - if (msg.type === "new_event") { - const ev = msg.data as DashboardEvent; - if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") { - load(); - } - } - if (msg.type === "run_status") { - loadDashboardRuns(); - } - }); - }, [load]); - - const loadDashboardRuns = useCallback(() => { - api.run - .list() - .then((r) => { - const ids = new Set(); - for (const h of r.items) { - if (h.sessionId) ids.add(h.sessionId); - } - setDashboardRunIds(ids); - }) - .catch(() => undefined); - }, []); - - useEffect(() => { - loadDashboardRuns(); - const t = setInterval(loadDashboardRuns, 15000); - return () => clearInterval(t); - }, [loadDashboardRuns]); - - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - - return ( -
-
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

- {t("sessionCount", { count: total })} - {filter ? ` ${filter}` : ""} -

-
-
- -
- -
-
-
- - setSearchInput(e.target.value)} - className="input w-full pl-10" - /> -
- -
- - -
- -
-
- - -
-
- -
-
- -
-
- {HARNESS_OPTIONS.map((opt) => ( - - ))} -
- -
- {FILTER_OPTIONS.map((opt) => ( - - ))} -
-
-
- - {!loading && sessions.length === 0 ? ( - - ) : ( - <> -
- - - - - - - - - - - - - - - {sessions.map((session) => ( - navigate(`/sessions/${session.id}`)} - className="hover:bg-surface-4 transition-colors cursor-pointer group" - > - - - - - - - - - - ))} - -
- {t("tableSession")} - - {t("tableStatus")} - - {t("tableLastActive")} - - {t("tableDuration")} - - {t("tableAgents")} - - {t("tableCost")} - - {t("tableDirectory")} -
-
-
-

- {session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} -

- - - {dashboardRunIds.has(session.id) && ( - e.stopPropagation()} - className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 hover:bg-emerald-500/20 hover:text-emerald-200 px-1.5 py-0.5 rounded-full transition-colors" - title={t("dashboardRunBadge", "Driven by Run page · click to open")} - > - - {t("common:dashboardRun", "Run")} - - )} -
-

- {session.id.slice(0, 12)} -

-
-
- - - {formatDateTime(session.last_activity || session.started_at)} - - {session.ended_at - ? formatDuration(session.started_at, session.ended_at) - : t("common:running")} - - {session.agent_count ?? "-"} - - {/* CLOSEDLOOP FEA-1433: never render a silent $0 for models the - token-cost engine (genai-prices) cannot price. A priced total - still shows; any unpriced models surface as an amber badge whose - tooltip names them, distinguishing "unpriced" from a real $0. */} - {session.unpriced_models && session.unpriced_models.length > 0 ? ( - - {session.cost != null && session.cost > 0 ? ( - {fmtCost(session.cost)} - ) : null} - - {session.cost != null && session.cost > 0 - ? t("costPartial", "partial") - : t("costUnpriced", "not priced")} - - - ) : session.cost != null && session.cost > 0 ? ( - fmtCost(session.cost) - ) : ( - "-" - )} - - {session.cwd ? truncate(session.cwd, 30) : "-"} - - -
-
- {totalPages > 1 && ( -
- - {t("common:pagination.showing", { - from: page * PAGE_SIZE + 1, - to: Math.min((page + 1) * PAGE_SIZE, total), - total, - })} - -
- - - {page + 1} / {totalPages} - - -
-
- )} - - )} -
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/Settings.tsx b/apps/desktop/scripts/agent-monitor-client/Settings.tsx deleted file mode 100644 index 717b0007..00000000 --- a/apps/desktop/scripts/agent-monitor-client/Settings.tsx +++ /dev/null @@ -1,1165 +0,0 @@ -/** - * @file Settings.tsx - * @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration. - * @author Son Nguyen - */ - -import { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react"; -import { useTranslation } from "react-i18next"; -import { - DollarSign, - RefreshCw, - Database, - Plug, - HardDrive, - AlertTriangle, - RotateCcw, - CheckCircle, - XCircle, - Server, - Bell, - BellOff, - BellRing, - FileDown, - Eraser, - Play, - Zap, - AlertCircle, - GitBranch, - ShieldCheck, - ShieldAlert, - ShieldX, - Clock, - Cpu, - Globe, - Wifi, - Activity, - Users, - Layers, - Coins, - BarChart3, - Settings as SettingsIcon, - FolderOpen, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { fmt, fmtCost } from "../lib/format"; -import { subscribeToPush, unsubscribeFromPush } from "../lib/push"; -import { Tip } from "../components/Tip"; -import { ImportHistory } from "../components/ImportHistory"; -// CLOSEDLOOP FEA-1433: the hand-editable pricing table is gone. genai-prices is -// the single source of truth for rates, so the ModelPricing CRUD type is no -// longer used by this read-only catalog view. -import type { WSMessage } from "../lib/types"; -// CLOSEDLOOP FEA-1434: shared two-ledger client helper. Settings is the writer -// of the "show hypothetical API cost" preference; the Dashboard reads it. -import { - loadLedgerPrefs, - saveLedgerPrefs, - type LedgerPrefs, -} from "../lib/closedloop-ledger"; - -// ─── Notification preferences ─── - -const NOTIF_KEY = "agent-monitor-notifications"; - -interface NotifPrefs { - enabled: boolean; - onNewSession: boolean; - onSessionError: boolean; - onSessionComplete: boolean; - onSubagentSpawn: boolean; -} - -const defaultNotif: NotifPrefs = { - enabled: false, - onNewSession: true, - onSessionError: true, - onSessionComplete: false, - onSubagentSpawn: false, -}; - -function loadNotifPrefs(): NotifPrefs { - try { - const raw = localStorage.getItem(NOTIF_KEY); - if (!raw) return { ...defaultNotif }; - return { ...defaultNotif, ...JSON.parse(raw) }; - } catch { - return { ...defaultNotif }; - } -} - -function saveNotifPrefs(prefs: NotifPrefs) { - localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs)); -} - -// ─── Helpers ─── - -// CLOSEDLOOP FEA-1433: the read-only pricing catalog reflects what the -// canonical token-cost engine (genai-prices) computed for the models actually -// used. Each row mirrors a /api/pricing/cost breakdown entry, including the -// `priced` flag and `unpriced_reason`, so unpriced models surface honestly -// instead of collapsing to a silent $0. The upstream CostBreakdown type does -// not declare these engine fields, so we type them locally. -interface PricedBreakdownRow { - model: string; - provider: string | null; - cost: number | null; - input_cost: number | null; - output_cost: number | null; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - priced: boolean; - unpriced_reason: string | null; -} - -interface PricingEngineStamp { - name: string; - version: string; -} - -interface SystemInfo { - db: { path: string; size: number; counts: Record }; - hooks: { installed: boolean; path: string; hooks: Record }; - server: { uptime: number; node_version: string; platform: string; ws_connections: number }; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function useCountUp(end: number | null, durationMs = 1000) { - const [count, setCount] = useState(0); - - useEffect(() => { - if (end === null) { - setCount(0); - return; - } - - let startTimestamp: number | null = null; - let animationFrameId: number; - const startValue = count; - - const step = (timestamp: number) => { - if (!startTimestamp) startTimestamp = timestamp; - const progress = Math.min((timestamp - startTimestamp) / durationMs, 1); - // easeOutQuart - const easeProgress = 1 - Math.pow(1 - progress, 4); - setCount(startValue + (end - startValue) * easeProgress); - - if (progress < 1) { - animationFrameId = window.requestAnimationFrame(step); - } else { - setCount(end); - } - }; - - animationFrameId = window.requestAnimationFrame(step); - return () => window.cancelAnimationFrame(animationFrameId); - }, [end, durationMs]); - - return count; -} - -// ─── Toggle component ─── - -function Toggle({ - checked, - onChange, - label, - description, -}: { - checked: boolean; - onChange: (v: boolean) => void; - label: string; - description?: string; -}) { - return ( - - ); -} - -// ─── Main component ─── - -export function Settings() { - const { t } = useTranslation("settings"); - // CLOSEDLOOP FEA-1433: read-only catalog state. `breakdown` is the engine's - // per-model output for the models actually used; `engine` is the genai-prices - // source-of-truth stamp. There is no editor state — the pricing table is no - // longer host-editable (genai-prices owns the rates). - const [breakdown, setBreakdown] = useState([]); - const [engine, setEngine] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [totalCost, setTotalCost] = useState(null); - const [sysInfo, setSysInfo] = useState(null); - const [actionLoading, setActionLoading] = useState(null); - const [actionResult, setActionResult] = useState<{ - key: string; - message: string; - isError: boolean; - } | null>(null); - const [confirmAction, setConfirmAction] = useState(null); - const [notifPrefs, setNotifPrefs] = useState(loadNotifPrefs); - // CLOSEDLOOP FEA-1434: per-user "show hypothetical API cost for subscription - // sessions" preference (localStorage, default off). Read by the Dashboard. - const [ledgerPrefs, setLedgerPrefs] = useState(loadLedgerPrefs); - const [abandonHours, setAbandonHours] = useState("24"); - const [purgeDays, setPurgeDays] = useState("90"); - const [claudeHome, setClaudeHomeState] = useState(""); - const [claudeHomeInput, setClaudeHomeInput] = useState(""); - const [claudeHomeSaving, setClaudeHomeSaving] = useState(false); - const [claudeHomeError, setClaudeHomeError] = useState(null); - - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - const animatedTotalCost = useCountUp(totalCost); - - const load = useCallback(async () => { - try { - const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([ - api.pricing.list(), - api.pricing.totalCost(), - api.settings.info(), - api.settings.claudeHome.get(), - ]); - // The sidecar's GET /api/pricing returns the genai-prices engine stamp - // (single source of truth) alongside the legacy rows; the upstream client - // type predates the stamp, so read it through a narrow cast. - setEngine((pricingRes as { engine?: PricingEngineStamp }).engine ?? null); - setTotalCost(costRes.total_cost); - // The cost breakdown carries the engine's per-model priced/unpriced verdict - // (fields the upstream CostBreakdown type does not declare). - setBreakdown((costRes.breakdown ?? []) as unknown as PricedBreakdownRow[]); - setSysInfo(infoRes); - setClaudeHomeState(claudeHomeRes.claude_home); - setClaudeHomeInput(claudeHomeRes.claude_home); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : t("messages.failedLoad")); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - const refreshInfo = () => - api.settings - .info() - .then(setSysInfo) - .catch(() => {}); - const interval = setInterval(refreshInfo, 10000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - return eventBus.subscribe((msg: WSMessage) => { - if ( - msg.type === "session_created" || - msg.type === "session_updated" || - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "new_event" - ) { - api.settings - .info() - .then(setSysInfo) - .catch(() => {}); - } - }); - }, []); - - useEffect(() => { - if (!actionResult) return; - const timeout = setTimeout(() => setActionResult(null), 5000); - return () => clearTimeout(timeout); - }, [actionResult]); - - const updateNotifPrefs = (patch: Partial) => { - setNotifPrefs((prev) => { - const next = { ...prev, ...patch }; - saveNotifPrefs(next); - return next; - }); - }; - - const updateLedgerPrefs = (patch: Partial) => { - setLedgerPrefs((prev) => { - const next = { ...prev, ...patch }; - saveLedgerPrefs(next); - return next; - }); - }; - - const requestNotifPermission = async () => { - if (!("Notification" in window)) return; - const perm = await Notification.requestPermission(); - if (perm === "granted") { - updateNotifPrefs({ enabled: true }); - await subscribeToPush(); - } - }; - - // CLOSEDLOOP FEA-1433: the add/edit/delete pricing handlers were removed. - // genai-prices is the single source of truth for rates, so there is no - // host-editable rule table to write to (the PUT/DELETE /api/pricing routes - // are gone). The catalog below is read-only. - - const runAction = async (key: string, fn: () => Promise) => { - setActionLoading(key); - setActionResult(null); - setConfirmAction(null); - try { - const message = await fn(); - setActionResult({ key, message, isError: false }); - await load(); - } catch (err) { - setActionResult({ - key, - message: t("messages.actionFailed", { - message: err instanceof Error ? err.message : t("messages.unknownError"), - }), - isError: true, - }); - } finally { - setActionLoading(null); - } - }; - - const handleClearData = () => - runAction("clear", async () => { - const res = await api.settings.clearData(); - const total = Object.values(res.cleared).reduce((s, n) => s + n, 0); - return t("danger.clearedResult", { count: total }); - }); - - const handleReinstallHooks = () => - runAction("hooks", async () => { - const res = await api.settings.reinstallHooks(); - return res.ok ? t("hooks.success") : t("hooks.failed"); - }); - - // CLOSEDLOOP FEA-1433: "Reset pricing to defaults" was removed. It reseeded - // the legacy model_pricing table, which no longer feeds any cost calculation - // (genai-prices owns the rates). There is nothing host-editable to reset. - - const handleCleanup = () => - runAction("cleanup", async () => { - const params: { abandon_hours?: number; purge_days?: number } = {}; - const ah = parseFloat(abandonHours); - const pd = parseFloat(purgeDays); - if (ah > 0) params.abandon_hours = ah; - if (pd > 0) params.purge_days = pd; - const res = await api.settings.cleanup(params); - const parts = []; - if (res.abandoned > 0) parts.push(`${res.abandoned}${t("data.abandonedResult")}`); - if (res.purged_sessions > 0) - parts.push( - `${res.purged_sessions}${t("data.purgedResult", { events: res.purged_events, agents: res.purged_agents })}` - ); - return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean"); - }); - - const handleSaveClaudeHome = async () => { - if (claudeHomeInput === claudeHome) return; - setClaudeHomeSaving(true); - setClaudeHomeError(null); - try { - const res = await api.settings.claudeHome.set(claudeHomeInput); - setClaudeHomeState(res.claude_home); - setClaudeHomeInput(res.claude_home); - } catch (err) { - setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed")); - } finally { - setClaudeHomeSaving(false); - } - }; - - // CLOSEDLOOP FEA-1433: the editable pricing-row form (renderEditCells) and its - // edit-mode derivations were removed. The catalog renders the engine's - // per-model verdict read-only; there is no inline editing. - const pricedRows = breakdown.filter((r) => r.priced); - const unpricedRows = breakdown.filter((r) => !r.priced); - - const actionBanner = (keys: string[]) => { - const match = actionResult && keys.includes(actionResult.key) ? actionResult : null; - if (!match) return null; - return ( -
- {match.message} -
- ); - }; - - if (loading) { - return ( -
- {t("common:loading")} -
- ); - } - - return ( -
- {/* Header */} -
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

{t("subtitle")}

-
-
-
- - - {t("exportData")} - - -
-
- - {/* Cost summary card */} -
-
-
-
- -
-
-

{t("common:cost.totalEstimatedCost")}

-

- - {totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"} - -

-
-
-
-

{t("acrossSessions")}

-

{t("basedOnUsage")}

-
-
-
- - {/* CLOSEDLOOP FEA-1434: two-ledger display preference. The headline total - counts only really-billed spend (metered API + unknown). Sessions - covered by a flat subscription (Claude Pro/Max, Codex, Cursor Pro, - Copilot) are priced as a hypothetical "would have cost" and kept out - of that total. This opt-in surfaces that hypothetical on the Dashboard; - it never changes the billed headline. Default off. */} -
-
- - updateLedgerPrefs({ showHypotheticalCost: v })} - label={t( - "ledger.showHypothetical", - "Show hypothetical API cost for subscription sessions", - )} - description={t( - "ledger.showHypotheticalDescription", - "Display what subscription-covered usage (Pro/Max, Codex, Cursor Pro, Copilot) would have cost at metered API rates. This is never added to the billed total.", - )} - /> -
-
- - {/* ─── MODEL PRICING (read-only catalog, FEA-1433) ─── - genai-prices is the single source of truth for rates. The host no - longer edits a pricing table; this section shows the engine version - stamp and the engine's per-model verdict for the models actually used, - surfacing unpriced models honestly instead of as a silent $0. */} -
-
-
-

- - {t("pricing.title")} - - {t("pricing.readOnlyBadge", "read-only")} - -

-

- {t( - "pricing.catalogDescription", - "Token costs are computed by the pricing engine below — the single source of truth for rates. This catalog is read-only and lists the models you've actually used.", - )} -

-
- {engine && ( - - - {engine.name} v{engine.version} - - )} -
- - {error && ( -
- {error} -
- )} - - {breakdown.length === 0 ? ( -
- {t( - "pricing.catalogEmpty", - "No token usage yet. Models will appear here with their engine-computed costs once you run sessions.", - )} -
- ) : ( -
- - - - - - - - - - - - - {pricedRows.map((row) => ( - - - - - - - - - ))} - {unpricedRows.map((row) => ( - - - - - - - - - ))} - -
- {t("common:cost.model")} - - {t("pricing.provider", "Provider")} - - {t("pricing.inputCost", "Input cost")} - - {t("pricing.outputCost", "Output cost")} - - {t("pricing.totalCost", "Cost")} - - {t("pricing.status", "Status")} -
{row.model}{row.provider ?? "—"} - {row.input_cost != null ? fmtCost(row.input_cost) : "—"} - - {row.output_cost != null ? fmtCost(row.output_cost) : "—"} - - {row.cost != null ? fmtCost(row.cost) : "—"} - - - {t("pricing.statusPriced", "priced")} - -
{row.model}{row.provider ?? "—"} - - {t("pricing.statusUnpriced", "not priced")} - -
-
- )} - - {unpricedRows.length > 0 && ( -

- {t("pricing.unpricedNote", { - count: unpricedRows.length, - defaultValue: - "{{count}} model(s) could not be priced by the engine and are excluded from cost totals. Update the pricing engine to add coverage.", - })} -

- )} -
- - {/* ─── HOOK CONFIGURATION ─── */} -
-

- - {t("hooks.title")} -

-

{t("hooks.description")}

- -
-
-
- {sysInfo?.hooks.installed ? ( - - {t("hooks.allInstalled")} - - ) : ( - - {t("hooks.incomplete")} - - )} -
- -
- - {actionBanner(["hooks"])} - - {sysInfo && ( - <> -
- {Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => ( -
- {active ? ( - - ) : ( - - )} - {hook} -
- ))} -
-

{sysInfo.hooks.path}

- - )} -
-
- - {/* ─── CLAUDE HOME ─── */} -
-

- - {t("claudeHome.title")} -

-

{t("claudeHome.description")}

- -
-
- { - setClaudeHomeInput(e.target.value); - setClaudeHomeError(null); - }} - className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-violet-500/50" - placeholder={t("claudeHome.placeholder")} - /> - -
- {claudeHomeError &&

{claudeHomeError}

} - {claudeHome && ( -

- {t("claudeHome.current")} {claudeHome} -

- )} -
-
- - {/* ─── IMPORT HISTORY ─── */} - - - {/* ─── NOTIFICATIONS ─── */} -
-

- - {t("notifications.title")} -

-

{t("notifications.description")}

- -
-
-
-
- {notifPrefs.enabled ? ( - - ) : ( - - )} -
- { - if (v) { - if ("Notification" in window && Notification.permission !== "granted") { - requestNotifPermission(); - } else { - updateNotifPrefs({ enabled: true }); - await subscribeToPush(); - } - } else { - updateNotifPrefs({ enabled: false }); - await unsubscribeFromPush(); - } - }} - label={t("notifications.enable")} - /> -
- {"Notification" in window && ( - - {Notification.permission === "granted" ? ( - - ) : Notification.permission === "denied" ? ( - - ) : ( - - )} - {Notification.permission === "granted" - ? t("notifications.granted") - : Notification.permission === "denied" - ? t("notifications.blocked") - : t("notifications.required")} - - )} -
- - {notifPrefs.enabled && ( -
-

- {t("notifications.notifyWhen")} -

-
-
- - updateNotifPrefs({ onNewSession: v })} - label={t("notifications.newSession")} - /> -
-
- - updateNotifPrefs({ onSessionComplete: v })} - label={t("notifications.sessionComplete")} - /> -
-
- - updateNotifPrefs({ onSessionError: v })} - label={t("notifications.sessionError")} - /> -
-
- - updateNotifPrefs({ onSubagentSpawn: v })} - label={t("notifications.subagentSpawned")} - /> -
-
- -
- -
-
- )} - - {!notifPrefs.enabled && ( -
- - {t("notifications.disabledInfo")} -
- )} -
-
- - {/* ─── DATA MANAGEMENT ─── */} -
-

- - {t("data.title")} -

-

{t("data.description")}

- -
-
-
-

- {t("data.dbOverview")} -

- {sysInfo && ( -
- - {sysInfo.db.path} -
- )} -
- - {sysInfo ? ( -
- {(() => { - const tableIcons: Record = { - sessions: , - agents: , - events: , - token_usage: , - model_pricing: , - }; - const tableLabels: Record = { - sessions: t("tables.sessions"), - agents: t("tables.agents"), - events: t("tables.events"), - token_usage: t("tables.sessionsWithCost"), - model_pricing: t("tables.pricingRules"), - }; - const tableColors: Record = { - sessions: "border-blue-500/20", - agents: "border-emerald-500/20", - events: "border-violet-500/20", - token_usage: "border-amber-500/20", - model_pricing: "border-cyan-500/20", - }; - return Object.entries(sysInfo.db.counts).map(([table, count]) => ( -
-
- {tableIcons[table] || } -

- {tableLabels[table] || table.replace(/_/g, " ")} -

-
-

- {fmt(count)} -

-
- )); - })()} -
-
- -

- {t("data.dbSize")} -

-
-

- {formatBytes(sysInfo.db.size)} -

-
-
- ) : ( -

{t("data.loadingDb")}

- )} -
- - {/* Session Cleanup */} -
-
-
- -
-
-

{t("data.sessionCleanup")}

-

{t("data.cleanupDesc")}

-
-
- -
-
- -
- setAbandonHours(e.target.value)} - className="input w-20 text-sm text-right font-mono" - /> - {t("common:hours")} -
-
-
- -
- setPurgeDays(e.target.value)} - className="input w-20 text-sm text-right font-mono" - /> - {t("common:days")} -
-
-
- - - - {actionBanner(["cleanup"])} -
- - {/* Danger zone */} -
-
-
- -
-
-

{t("danger.title")}

-

{t("danger.description")}

-
-
- - {confirmAction === "clear" ? ( -
- {t("danger.warning")} -
- - -
-
- ) : ( - - )} - - {actionBanner(["clear"])} -
-
-
- - {/* ─── ABOUT ─── */} -
-

- - {t("about.title")} -

-

{t("about.description")}

- - {sysInfo ? ( -
-
-
-
- -

- {t("about.uptime")} -

-
-

- {formatUptime(sysInfo.server.uptime)} -

-
-
-
- -

- {t("about.nodejs")} -

-
-

- {sysInfo.server.node_version} -

-
-
-
- -

- {t("about.platform")} -

-
-

{sysInfo.server.platform}

-
-
-
- -

- {t("about.wsClients")} -

-
-

- {sysInfo.server.ws_connections} -

-
-
-
- ) : ( -

{t("about.loadingInfo")}

- )} -
-
- ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx b/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx deleted file mode 100644 index e2f1236c..00000000 --- a/apps/desktop/scripts/agent-monitor-client/StatusBadge.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file StatusBadge.tsx - * @description Defines reusable React components for displaying the status of - * agents and sessions in a visually distinct way using badges. - */ -import { useTranslation } from "react-i18next"; -import { STATUS_CONFIG, SESSION_STATUS_CONFIG } from "../lib/types"; -import type { EffectiveAgentStatus, EffectiveSessionStatus } from "../lib/types"; -import { - isSubscriptionMode, - subscriptionBadgeLabel, -} from "../lib/closedloop-ledger"; - -interface AgentStatusBadgeProps { - status: EffectiveAgentStatus; - pulse?: boolean; -} - -export function AgentStatusBadge({ status, pulse }: AgentStatusBadgeProps) { - const { t } = useTranslation(); - const config = STATUS_CONFIG[status]; - const shouldPulse = pulse ?? (status === "working" || status === "waiting"); - - return ( - - - {t(config.labelKey)} - - ); -} - -interface SessionStatusBadgeProps { - status: EffectiveSessionStatus; - pulse?: boolean; -} - -export function SessionStatusBadge({ status, pulse }: SessionStatusBadgeProps) { - const { t } = useTranslation(); - const config = SESSION_STATUS_CONFIG[status]; - const shouldPulse = pulse ?? status === "waiting"; - return ( - - {shouldPulse && ( - - ); -} - -export function HarnessBadge({ harness }: { harness?: string | null }) { - const h = (harness || "claude").toLowerCase(); - const config: Record = { - codex: { - label: "Codex", - cls: "bg-sky-500/10 text-sky-300 border border-sky-500/20", - }, - cursor: { - label: "Cursor", - cls: "bg-amber-500/10 text-amber-300 border border-amber-500/20", - }, - copilot: { - label: "Copilot", - cls: "bg-green-500/10 text-green-300 border border-green-500/20", - }, - opencode: { - label: "OpenCode", - cls: "bg-rose-500/10 text-rose-300 border border-rose-500/20", - }, - }; - const { label, cls } = config[h] || { - label: "Claude", - cls: "bg-violet-500/10 text-violet-300 border border-violet-500/20", - }; - return {label}; -} - -// CLOSEDLOOP FEA-1434: per-session billing signal. Renders ONLY for -// subscription-covered sessions (Claude Pro/Max, Codex, Cursor Pro, Copilot -// seat) — the honest quota signal asked for by PRD-414. There is no fabricated -// quota percentage: existence-only detection cannot resolve a $100-vs-$200 tier -// or remaining quota, so we surface the billing mode itself plus a tooltip -// explaining the spend is subscription-covered (not billed per token). Metered -// and unknown sessions get no badge — their real cost already shows in the cost -// cell. Classification is presentation-only (see lib/closedloop-ledger.ts). -export function BillingBadge({ billing_mode }: { billing_mode?: string | null }) { - if (!isSubscriptionMode(billing_mode)) return null; - return ( - - {subscriptionBadgeLabel(billing_mode)} - - ); -} diff --git a/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts b/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts deleted file mode 100644 index a6369ce1..00000000 --- a/apps/desktop/scripts/agent-monitor-client/lib/closedloop-ledger.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @file closedloop-ledger.ts - * @description ClosedLoop-authored client helper (FEA-1434) for the two-ledger - * cost UI. Copied to `src/lib/closedloop-ledger.ts` at build time by - * scripts/build-agent-monitor.mjs via CLIENT_FULL_FILE_OVERRIDES, then bundled - * by Vite. It centralises, in ONE place shared by every overlay: - * 1. the localStorage-backed "show hypothetical API cost for subscription - * sessions" preference (Settings writes it, the Dashboard reads it); - * 2. the `CostByLedger` shape the /api/pricing/cost and /api/analytics - * endpoints attach as `cost_by_ledger`; and - * 3. presentation-only billing-mode classification + labels for the - * per-session "subscription-covered" badge. - * - * IMPORTANT — no cost math happens here. The canonical token-cost engine - * (genai-prices) and the server's ledger split - * (apps/desktop/scripts/agent-monitor-billing/billing-mode.js → billingLedger) - * own every dollar figure. SUBSCRIPTION_BILLING_MODES below is a presentation - * mirror of the SUBSCRIPTION_MODES set in that server module, used only to - * decide whether to draw a badge and what to label it. A drift here can only - * mislabel a cosmetic badge — it can never change a headline or ledger total, - * which are computed server-side and never recomputed on the client. - */ - -// ─── "Show hypothetical API cost" preference (localStorage) ─── - -export const LEDGER_PREFS_KEY = "agent-monitor-ledger"; - -export interface LedgerPrefs { - /** - * When true the Dashboard surfaces the hypothetical API cost of - * subscription-covered usage (the "would have cost"). Off by default so the - * headline shows only really-billed spend (metered + unknown); the - * subscription bucket is a hypothetical and is never summed into the - * headline regardless of this flag. - */ - showHypotheticalCost: boolean; -} - -export const defaultLedgerPrefs: LedgerPrefs = { - showHypotheticalCost: false, -}; - -export function loadLedgerPrefs(): LedgerPrefs { - try { - const raw = localStorage.getItem(LEDGER_PREFS_KEY); - if (!raw) return { ...defaultLedgerPrefs }; - return { ...defaultLedgerPrefs, ...JSON.parse(raw) }; - } catch { - return { ...defaultLedgerPrefs }; - } -} - -export function saveLedgerPrefs(prefs: LedgerPrefs): void { - localStorage.setItem(LEDGER_PREFS_KEY, JSON.stringify(prefs)); -} - -// ─── Two-ledger totals shape ─── - -/** - * The three-bucket totals the cost + analytics endpoints attach as - * `cost_by_ledger`. Headline cost = metered + unknown; `subscription` is the - * hypothetical "would have cost" and is never summed into the headline. The - * upstream CostResult/Analytics types predate this field, so consumers read it - * through this locally-declared shape. - */ -export interface CostByLedger { - metered: number; - subscription: number; - unknown: number; -} - -// ─── Billing-mode presentation (mirror of server SSOT, badge-only) ─── - -/** - * Presentation mirror of SUBSCRIPTION_MODES in the server billing-mode engine - * (apps/desktop/scripts/agent-monitor-billing/billing-mode.js). Used ONLY to - * decide whether a session is subscription-covered for the badge — never for - * cost math. Keep in sync with the server set; a mismatch only affects a badge. - */ -export const SUBSCRIPTION_BILLING_MODES: ReadonlySet = new Set([ - "subscription_unknown", - "pro", - "max_5x", - "max_20x", - "codex_subscription", - "cursor_pro", - "copilot_seat", -]); - -/** True when a stored billing_mode represents subscription-covered usage. */ -export function isSubscriptionMode(mode: string | null | undefined): boolean { - return mode != null && SUBSCRIPTION_BILLING_MODES.has(mode); -} - -/** - * Human-friendly label for a subscription billing_mode shown on the badge. - * Existence-only detection can't resolve Anthropic tiers yet (subscription_unknown - * → "Subscription"); finer tiers (Pro / Max 5x / Max 20x) arrive once `/status` - * parsing lands (out of scope for this slice, PRD-414). Non-subscription modes - * never reach this function (the badge is drawn only for subscription sessions). - */ -export function subscriptionBadgeLabel(mode: string | null | undefined): string { - switch (mode) { - case "pro": - return "Pro"; - case "max_5x": - return "Max 5x"; - case "max_20x": - return "Max 20x"; - case "codex_subscription": - return "Codex"; - case "cursor_pro": - return "Cursor Pro"; - case "copilot_seat": - return "Copilot"; - case "subscription_unknown": - default: - return "Subscription"; - } -} diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt deleted file mode 100644 index 666c4162..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessioncard.badge.replace.txt +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
\ No newline at end of file diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt deleted file mode 100644 index cc2c1624..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.find.txt +++ /dev/null @@ -1,2 +0,0 @@ - {/* Status Filters */} -
diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt deleted file mode 100644 index eb65c816..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.filterui.replace.txt +++ /dev/null @@ -1,19 +0,0 @@ - {/* Harness Filter (Addition #6) */} -
- {HARNESS_OPTIONS.map((opt) => ( - - ))} -
- - {/* Status Filters */} -
diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt deleted file mode 100644 index 7d934144..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.find.txt +++ /dev/null @@ -1,3 +0,0 @@ - const waiting = res.sessions.filter(isSessionAwaitingInput); - setTotal(waiting.length); - setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt deleted file mode 100644 index a584899c..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.legacy.find.txt +++ /dev/null @@ -1,6 +0,0 @@ - let rows = res.sessions; - if (filter === "waiting") rows = rows.filter(isSessionAwaitingInput); - if (harness) - rows = rows.filter((s) => (s.harness || "claude").toLowerCase() === harness); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt deleted file mode 100644 index ce1ea103..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadrows.replace.txt +++ /dev/null @@ -1,4 +0,0 @@ - let rows = res.sessions; - rows = rows.filter(isSessionAwaitingInput); - setTotal(rows.length); - setSessions(rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)); diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt deleted file mode 100644 index 1a9f8f60..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.find.txt +++ /dev/null @@ -1,7 +0,0 @@ - // The "waiting" filter is a UI-only overlay derived from the - // awaiting_input_since column — the underlying SessionStatus is - // still "active". Map it to a client-side filter on top of the - // active set so paging/totals stay consistent with the visible rows. - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt deleted file mode 100644 index aa8b6c82..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.legacy.find.txt +++ /dev/null @@ -1,9 +0,0 @@ - // Two UI-only overlays need client-side filtering on a broad fetch so - // paging/totals stay consistent with the visible rows: - // - "waiting" derived from awaiting_input_since (status is "active"). - // - harness derived from the harness column (Addition #6); the - // vendored /api/sessions route is unpatched so we filter here. - // Legacy/empty harness counts as "claude" (matches the DB default). - if (filter === "waiting" || harness) { - const res = await api.sessions.list({ - status: filter === "waiting" ? "active" : filter || undefined, diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt deleted file mode 100644 index d5a3a36b..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.loadtop.replace.txt +++ /dev/null @@ -1,15 +0,0 @@ - // The "waiting" filter is a UI-only overlay derived from the - // awaiting_input_since column — the underlying SessionStatus is - // still "active". Map it to a client-side filter on top of the - // server-side status + harness filters so paging/totals stay - // consistent with the visible rows. - if (filter === "waiting") { - const res = await api.sessions.list({ - status: "active", - q: search || undefined, - cwd: cwd || undefined, - harness: harness || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - limit: 10000, - offset: 0, diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt deleted file mode 100644 index ebbf1a8e..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.find.txt +++ /dev/null @@ -1,2 +0,0 @@ -

- {dashboardRunIds.has(session.id) && ( diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt deleted file mode 100644 index c55bcc37..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.rowbadge.replace.txt +++ /dev/null @@ -1,3 +0,0 @@ -

- - {dashboardRunIds.has(session.id) && ( diff --git a/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt b/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt deleted file mode 100644 index a2c813a4..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/sessions.state.replace.txt +++ /dev/null @@ -1,14 +0,0 @@ - const [dashboardRunIds, setDashboardRunIds] = useState>(new Set()); - // CLOSEDLOOP multi-harness support: filter by agent harness. "" = all. - // Applied client-side (the vendored /api/sessions route is unpatched); - // legacy/empty harness values count as "claude" (DB column default). - const [harness, setHarness] = useState(""); - - const HARNESS_OPTIONS: Array<{ label: string; value: string }> = [ - { label: "All Harnesses", value: "" }, - { label: "Claude", value: "claude" }, - { label: "Codex", value: "codex" }, - { label: "Cursor", value: "cursor" }, - { label: "Copilot", value: "copilot" }, - { label: "OpenCode", value: "opencode" }, - ]; \ No newline at end of file diff --git a/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx b/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx deleted file mode 100644 index 821acb0f..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/client/statusbadge.append.tsx +++ /dev/null @@ -1,17 +0,0 @@ - - -/** - * CLOSEDLOOP multi-harness support: which agent harness produced a session. - * Legacy/empty values render as "Claude" to match the DB default. - */ -export function HarnessBadge({ harness }: { harness?: string | null }) { - const h = (harness || "claude").toLowerCase(); - const config: Record = { - codex: { label: "Codex", cls: "bg-sky-500/10 text-sky-300 border border-sky-500/20" }, - cursor: { label: "Cursor", cls: "bg-amber-500/10 text-amber-300 border border-amber-500/20" }, - copilot: { label: "Copilot", cls: "bg-green-500/10 text-green-300 border border-green-500/20" }, - opencode: { label: "OpenCode", cls: "bg-rose-500/10 text-rose-300 border border-rose-500/20" }, - }; - const { label, cls } = config[h] || { label: "Claude", cls: "bg-violet-500/10 text-violet-300 border border-violet-500/20" }; - return {label}; -} diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-home.js b/apps/desktop/scripts/agent-monitor-codex/codex-home.js deleted file mode 100644 index 8dda6249..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-home.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file codex-home.js - * @description Centralized OpenAI Codex CLI home directory path management — - * the Codex analogue of claude-home.js. Resolves the sessions root, the - * rollout JSONL files (Codex writes one append-only `rollout-*.jsonl` per - * session under `sessions/YYYY/MM/DD/`), the aggregated history file, and the - * archived-sessions directory. Supports a custom root via the CODEX_HOME - * environment variable so non-default Codex installs are still discovered. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); - -function getCodexHome() { - // Codex accepts a comma-separated CODEX_HOME in some setups; the first entry - // is the active root. Fall back to ~/.codex. - const raw = process.env.CODEX_HOME; - if (raw && raw.trim()) { - const first = raw.split(",")[0].trim(); - if (first) return first.replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".codex"); -} - -function getCodexSessionsDir() { - return path.join(getCodexHome(), "sessions"); -} - -function getCodexArchivedDir() { - return path.join(getCodexHome(), "archived_sessions"); -} - -function getCodexHistoryPath() { - return path.join(getCodexHome(), "history.jsonl"); -} - -/** - * Derive a stable session id from a rollout file path. Codex names rollout - * files `rollout--.jsonl`; we want the uuid. If the name - * doesn't match, fall back to the basename sans extension so every file still - * maps to a deterministic id. - */ -function sessionIdFromRolloutPath(filePath) { - const base = path.basename(filePath, ".jsonl"); - const uuid = base.match( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i - ); - if (uuid) return uuid[0]; - return base.replace(/^rollout-/, ""); -} - -/** - * Recursively collect every `*.jsonl` rollout file under a root directory. - * Codex nests by date (`sessions/YYYY/MM/DD/`), but we walk generically so a - * flat layout or `archived_sessions/` also works. Depth-bounded and - * error-tolerant — a Codex dir is the user's own local data and a permission - * or IO error on one branch must not abort discovery. - */ -function collectRolloutFiles(root, { maxDepth = 8 } = {}) { - const out = []; - if (!root || !fs.existsSync(root)) return out; - const walk = (dir, depth) => { - if (depth > maxDepth) return; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walk(full, depth + 1); - } else if (e.isFile() && e.name.endsWith(".jsonl")) { - out.push(full); - } - } - }; - walk(root, 0); - return out; -} - -/** - * All Codex rollout files (active sessions + archived). - */ -function listAllRolloutFiles() { - return [ - ...collectRolloutFiles(getCodexSessionsDir()), - ...collectRolloutFiles(getCodexArchivedDir()), - ]; -} - -module.exports = { - getCodexHome, - getCodexSessionsDir, - getCodexArchivedDir, - getCodexHistoryPath, - sessionIdFromRolloutPath, - collectRolloutFiles, - listAllRolloutFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-import.js b/apps/desktop/scripts/agent-monitor-codex/codex-import.js deleted file mode 100644 index a5390421..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-import.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @file codex-import.js - * @description Bootstrap importer for OpenAI Codex CLI sessions — the Codex - * analogue of scripts/import-history.js `importAllSessions`. It parses each - * Codex rollout JSONL into the shared normalized session shape and then reuses - * the existing, battle-tested `importSession()` so Codex sessions land in the - * same sessions/agents/events/token_usage rows and render through the - * unchanged dashboard UI. The only Codex-specific step is stamping - * `harness='codex'` on the row afterwards (the shared insert defaults to - * 'claude'); `setSessionHarness` is idempotent. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const { parseRolloutFile } = require("./codex-parser"); -const { listAllRolloutFiles } = require("./codex-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Cache of (path, mtime, size) for rollout files already parsed and imported. -// The catchup poll runs every 5 s and would otherwise re-parse every file on -// every tick (FEA-1316); the persisted backing file additionally lets a fresh -// process skip unchanged files on the cold-start boot import (FEA-1334). -const catchupCache = createCatchupCache({ persistPath: ingestCachePath("codex") }); - -/** - * Import (or idempotently backfill) a single Codex rollout file. - * Returns { sessionId, result } where result is importSession's return value, - * or { skipped: true } when the file has no usable content. - */ -function importCodexSession(dbModule, session) { - const result = importSession(dbModule, session); - // Stamp the harness regardless of skipped/backfilled — cheap, idempotent, - // and self-heals rows imported before the `harness` column existed. - try { - dbModule.stmts.setSessionHarness.run("codex", session.sessionId, "codex"); - } catch { - /* non-fatal — column/stmt guaranteed by db.js Patch #4 */ - } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "codex", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Codex rollout file. Designed to be cheap on - * repeat runs: importSession skips already-imported sessions (or backfills - * only genuinely-new events via its per-event-type high-water-mark). - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - * Returns { imported, skipped, errors }. - */ -async function importAllCodexSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - const files = listAllRolloutFiles(); - if (onBegin) onBegin(files.length); - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCodexSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - // Parse outside the transaction (async IO); apply inside one (sync, fast). - // Skip files whose (mtime, size) is unchanged since the last successful - // parse — that is the common case for the 5 s catchup poll. Without this - // gate every tick re-parses every historical rollout file (FEA-1316). - const batch = []; - const parsedEntries = []; - for (const filePath of files) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = catchupCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseRolloutFile(filePath); - if (!session) { - // Cache even null-parsed files so we don't re-read them every tick. - catchupCache.markSeenWith(filePath, stat); - skipped++; - continue; - } - batch.push(session); - parsedEntries.push({ path: filePath, stat }); - } catch { - errors++; - } - } - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of parsedEntries) catchupCache.markSeenWith(path, stat); - catchupCache.pruneTo(files); - catchupCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCodexSessions, importCodexSession }; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-parser.js b/apps/desktop/scripts/agent-monitor-codex/codex-parser.js deleted file mode 100644 index 30c01737..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-parser.js +++ /dev/null @@ -1,368 +0,0 @@ -/** - * @file codex-parser.js - * @description Parse an OpenAI Codex CLI rollout JSONL file into the SAME - * normalized session object that scripts/import-history.js `parseSessionFile` - * produces for Claude Code. Emitting an identical shape lets the Codex import - * path reuse the existing, battle-tested `importSession()` so Codex sessions - * render through the unchanged dashboard UI exactly like Claude sessions. - * - * Codex's rollout format has drifted across releases, so parsing is - * intentionally tolerant: it accepts the modern RolloutLine envelope - * (`{type:"session_meta"|"event_msg"|"response_item", payload, timestamp}`), - * older bare records (the item itself on the line), and auto-detects a typed - * `payload` under an unknown wrapper. Token usage in Codex `token_count` - * events is CUMULATIVE per session, so the final value is the session total - * (no delta math needed). Model attribution follows CodexBar's documented - * rule: `turn_context.model` is authoritative. - * - * Reference for the Codex format & token/model semantics: steipete/CodexBar - * `docs/codex.md` (MIT) — see THIRD_PARTY_NOTICES.md. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { sessionIdFromRolloutPath } = require("./codex-home"); -const { pushTurnDuration, toIso, safeJson } = require("../agent-monitor-shared/parser-utils"); - -const RESPONSE_ITEM_TYPES = new Set([ - "message", - "reasoning", - "function_call", - "function_call_output", - "local_shell_call", - "local_shell_call_output", - "custom_tool_call", - "custom_tool_call_output", -]); - -// CLOSEDLOOP plan-extraction (FEA-1189): Codex emits implementation plans as a -// structured `item_completed` event whose item.type === "Plan", and (fallback) -// as a block inside an assistant message. We surface both into -// session.plans[]; plan-extractor/plan-store handle normalization + versioning. -const PROPOSED_PLAN_RE = /([\s\S]*?)<\/proposed_plan>/i; -/** - * Classify a parsed JSONL record into a coarse kind plus its inner payload. - */ -function classify(rec) { - if (!rec || typeof rec !== "object") return null; - const ts = rec.timestamp || rec.ts || (rec.payload && rec.payload.timestamp) || null; - const t = rec.type; - - if (t === "session_meta" || t === "session.created") - return { kind: "session_meta", p: rec.payload || rec, ts }; - if (t === "turn_context" || t === "turn.context") - return { kind: "turn_context", p: rec.payload || rec, ts }; - if (t === "event_msg" || t === "event") - return { kind: "event", p: rec.payload || rec, ts }; - if (t === "response_item" || t === "response.item") - return { kind: "response_item", p: rec.payload || rec, ts }; - - // Unknown wrapper but a typed payload — auto-detect from payload.type. - if (rec.payload && typeof rec.payload === "object" && rec.payload.type) { - return { kind: "auto", p: rec.payload, ts }; - } - // Bare Responses-API item on the line. - if (t && RESPONSE_ITEM_TYPES.has(t)) return { kind: "response_item", p: rec, ts }; - // Bare session meta (no `type`, but session-ish fields). - if (!t && (rec.cwd || rec.instructions || rec.git || rec.session_id || rec.id)) { - return { kind: "session_meta", p: rec, ts }; - } - // Bare event-like record. - if (t) return { kind: "event", p: rec, ts }; - return { kind: "other", p: rec.payload || rec, ts }; -} - -function extractText(content) { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts = []; - for (const b of content) { - if (!b) continue; - if (typeof b === "string") parts.push(b); - else if (typeof b.text === "string") parts.push(b.text); - else if (b.type === "input_text" || b.type === "output_text" || b.type === "text") { - if (typeof b.text === "string") parts.push(b.text); - } - } - return parts.join(""); -} - -/** - * Parse a single Codex rollout JSONL file into the normalized session object. - * Returns null when the file carries no usable timestamp (mirrors - * parseSessionFile's contract so importSession can treat both identically). - */ -async function parseRolloutFile(filePath) { - const sessionId = sessionIdFromRolloutPath(filePath); - - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let gitBranch = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const plans = []; // CLOSEDLOOP plan-extraction (FEA-1189) - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let latestTotals = null; // cumulative token_count totals (last wins) - let sawResponseItems = false; - let lastTs = null; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - lastTs = iso; - return iso; - }; - - const handleResponseItem = (p, iso, explicitIso) => { - sawResponseItems = true; - const itype = p.type; - if (itype === "message") { - const role = p.role || p.author || "assistant"; - const text = extractText(p.content); - if (role === "user") { - userMessageCount++; - if (explicitIso) pendingTurnStartedAt = explicitIso; - } else { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - // Fallback plan signal: block in an assistant message - // (medium confidence — flagged for user confirmation downstream). - const pm = PROPOSED_PLAN_RE.exec(text); - if (pm && pm[1] && pm[1].trim()) { - plans.push({ - source: "codex-proposed-plan", - content: pm[1].trim(), - timestamp: iso || firstTimestamp, - }); - } - } - void text; - } else if (itype === "reasoning") { - thinkingBlockCount++; - } else if (itype === "function_call" || itype === "custom_tool_call") { - toolUses.push({ - name: p.name || p.tool_name || "function", - timestamp: iso || firstTimestamp, - input: safeJson(p.arguments != null ? p.arguments : p.input), - }); - } else if (itype === "local_shell_call") { - const action = p.action || {}; - toolUses.push({ - name: "shell", - timestamp: iso || firstTimestamp, - input: action.command || action || p.input || null, - }); - } else if ( - itype === "function_call_output" || - itype === "custom_tool_call_output" || - itype === "local_shell_call_output" - ) { - const out = p.output || p.result || {}; - const isErr = - out && typeof out === "object" - ? out.success === false || out.is_error === true || !!out.error - : false; - if (isErr) { - const content = - typeof out === "string" - ? out.slice(0, 500) - : JSON.stringify(out).slice(0, 500); - toolResultErrors.push({ content, timestamp: iso }); - } - } - }; - - const handleEvent = (p, iso, explicitIso) => { - const et = p.type; - if (!et) return; - // CLOSEDLOOP plan-extraction (FEA-1189): the strongest Codex plan signal — - // a structured item_completed event carrying item.type === "Plan". - if ( - et === "item_completed" && - p.item && - p.item.type === "Plan" && - typeof p.item.text === "string" && - p.item.text.trim() - ) { - plans.push({ - source: "codex-plan-item", - content: p.item.text, - timestamp: iso || firstTimestamp, - }); - return; - } - if (et === "user_message") { - userMessageCount++; - if (explicitIso) pendingTurnStartedAt = explicitIso; - } else if (et === "agent_message" || et === "agent_message_delta") { - if (et === "agent_message") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - } else if (et === "agent_reasoning" || et === "agent_reasoning_section_break") { - if (et === "agent_reasoning") thinkingBlockCount++; - } else if (et === "token_count") { - const info = p.info || p.token_count_info || p; - const totals = - info.total_token_usage || info.totalTokenUsage || info.total || null; - if (totals && typeof totals === "object") latestTotals = totals; - const m = - (p.turn_context && p.turn_context.model) || info.model || p.model; - if (m) model = m; - } else if (et === "error" || et === "stream_error") { - apiErrors.push({ - type: et, - message: - (typeof p.message === "string" && p.message) || - p.error || - "Codex error", - timestamp: iso, - }); - } else if ( - !sawResponseItems && - (et === "exec_command_begin" || - et === "patch_apply_begin" || - et === "mcp_tool_call_begin") - ) { - // Fallback only for older event-only logs with no response_item items. - const name = - et === "exec_command_begin" - ? "shell" - : et === "patch_apply_begin" - ? "apply_patch" - : p.tool || p.server || "mcp_tool"; - toolUses.push({ - name, - timestamp: iso || firstTimestamp, - input: p.command || p.changes || p.arguments || null, - }); - } - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { - rec = JSON.parse(line); - } catch { - continue; - } - const c = classify(rec); - if (!c) continue; - const explicitIso = noteTs(c.ts); - const iso = explicitIso || lastTs; - - if (c.kind === "session_meta") { - const p = c.p || {}; - if (!cwd && (p.cwd || p.workdir)) cwd = p.cwd || p.workdir; - if (!version && (p.cli_version || p.version)) version = p.cli_version || p.version; - if (!gitBranch) { - if (typeof p.git === "object" && p.git) gitBranch = p.git.branch || p.git.ref || null; - else if (typeof p.git_branch === "string") gitBranch = p.git_branch; - } - if (!model && p.model) model = p.model; - } else if (c.kind === "turn_context") { - const p = c.p || {}; - if (p.model) model = p.model; // authoritative - if (!cwd && p.cwd) cwd = p.cwd; - } else if (c.kind === "response_item") { - handleResponseItem(c.p || {}, iso, explicitIso); - } else if (c.kind === "event") { - handleEvent(c.p || {}, iso, explicitIso); - } else if (c.kind === "auto") { - const p = c.p || {}; - if (RESPONSE_ITEM_TYPES.has(p.type)) handleResponseItem(p, iso, explicitIso); - else handleEvent(p, iso, explicitIso); - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (latestTotals) { - const key = model || "gpt-codex"; - const input = latestTotals.input_tokens || latestTotals.inputTokens || 0; - const cached = - latestTotals.cached_input_tokens || latestTotals.cachedInputTokens || 0; - const output = latestTotals.output_tokens || latestTotals.outputTokens || 0; - const reasoning = - latestTotals.reasoning_output_tokens || - latestTotals.reasoningOutputTokens || - 0; - const cacheWrite = - latestTotals.cache_write_tokens || - latestTotals.cacheWriteTokens || - latestTotals.cache_creation_input_tokens || - latestTotals.cacheCreationInputTokens || - 0; - if (input || output || cached || reasoning || cacheWrite) { - tokensByModel[key] = { - input, - output: output + reasoning, - cacheRead: cached, - cacheWrite, - }; - } - } - - let fileModifiedAt = null; - try { - fileModifiedAt = fs.statSync(filePath).mtimeMs; - } catch { - /* non-fatal */ - } - - const projectName = cwd ? path.basename(cwd) : `Codex Session ${sessionId.slice(0, 8)}`; - - return { - sessionId, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - plans, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "codex", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseRolloutFile, classify }; diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js b/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js deleted file mode 100644 index 6e5e104a..00000000 --- a/apps/desktop/scripts/agent-monitor-codex/codex-watcher.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * @file codex-watcher.js - * @description Live file watcher for OpenAI Codex CLI sessions. Codex has NO - * hook system (unlike Claude Code, whose live data arrives via hooks), so the - * ONLY way to keep the dashboard current for Codex is to watch the rollout - * JSONL files Codex appends to under `~/.codex/sessions/YYYY/MM/DD/`. - * - * On a debounced change it re-parses the affected rollout file and runs the - * shared idempotent importer (importSession backfills only genuinely-new - * events via its per-event-type high-water-mark), then broadcasts the updated - * session/agent rows over the existing dashboard WebSocket so the unchanged - * client live-updates exactly like it does for Claude hook events. - * - * Best-effort and non-fatal, mirroring cc-watcher.js: `fs.watch` is - * platform-quirky; a failure here must never crash the sidecar. A full - * startup catch-up still happens via codex-import.js. - * - * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). - */ -const fs = require("fs"); -const path = require("path"); -const { getCodexSessionsDir } = require("./codex-home"); -const { parseRolloutFile } = require("./codex-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; - -let started = false; -let timer = null; -let retryTimer = null; -let catchupTimer = null; -let pending = new Set(); -const watchers = []; - -function processPending(broadcast) { - const files = Array.from(pending); - pending = new Set(); - if (files.length === 0) return; - - // Lazy-require to avoid load-order coupling with db/import-history. - let dbModule; - let importCodexSession; - try { - dbModule = require("../db"); - ({ importCodexSession } = require("./codex-import")); - } catch { - return; - } - - (async () => { - for (const filePath of files) { - let session; - try { - session = await parseRolloutFile(filePath); - } catch { - continue; - } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCodexSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { - /* non-fatal — a partially-written rollout line is normal mid-turn */ - } - } - })(); -} - -function scheduleProcess(broadcast, filePath) { - if (filePath) pending.add(filePath); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { - processPending(broadcast); - } catch { - /* ignore */ - } - }, DEBOUNCE_MS); -} - -function safeWatchSessions({ root, broadcast }) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!String(filename).endsWith(".jsonl")) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { - /* platform limitation — startup import still covers historical sessions */ - return false; - } -} - -// One-time catch-up after the sessions dir appears for the first time AFTER -// the sidecar booted (e.g. the user ran their first-ever Codex session while -// the dashboard was already open). server/index.js's startup import ran when -// the dir didn't exist yet and fs.watch only fires for events AFTER it -// attaches, so without this the session stays invisible until an app -// restart. Re-imports are idempotent; broadcasts make an open UI refresh. -function runCatchupImport(broadcast) { - let dbModule; - let importAllCodexSessions; - try { - dbModule = require("../db"); - ({ importAllCodexSessions } = require("./codex-import")); - } catch { - return; - } - Promise.resolve() - .then(() => importAllCodexSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "codex"); - } - }) - .catch(() => {}); -} - -/** - * Start watching Codex rollout files. Idempotent: subsequent calls are no-ops. - * Resilient to a not-yet-existent ~/.codex/sessions: if the dir is missing at - * boot we poll (cheap fs.existsSync) until it appears, then run a one-time - * catch-up import and attach the recursive watcher. - */ -function startCodexWatcher({ broadcast }) { - if (started) return; - started = true; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - const root = getCodexSessionsDir(); - if (safeWatchSessions({ root, broadcast })) return; // dir existed → attached - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - let retryCount = 0; - retryTimer = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(retryTimer); - retryTimer = null; - return; - } - if (!fs.existsSync(root)) return; - if (safeWatchSessions({ root, broadcast })) { - clearInterval(retryTimer); - retryTimer = null; - runCatchupImport(broadcast); - } - }, RETRY_MS); - retryTimer.unref?.(); -} - -function stopCodexWatcher() { - if (timer) { - clearTimeout(timer); - timer = null; - } - if (retryTimer) { - clearInterval(retryTimer); - retryTimer = null; - } - if (catchupTimer) { - clearInterval(catchupTimer); - catchupTimer = null; - } - for (const w of watchers) { - try { - w.close(); - } catch { - /* ignore */ - } - } - watchers.length = 0; - pending = new Set(); - started = false; -} - -module.exports = { startCodexWatcher, stopCodexWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js deleted file mode 100644 index af18aded..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-home.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @file copilot-home.js - * @description Centralized GitHub Copilot session path management. Resolves - * paths for: - * - * 1. Copilot Chat (VS Code extension): JSON session files under - * ~/Library/Application Support/Code/User/workspaceStorage//chatSessions/ - * - * 2. Copilot CLI (`gh copilot`): JSONL event logs under - * ~/.copilot/session-state//events.jsonl - * - * Both locations are scanned opportunistically — if neither exists the tool - * is simply not installed or hasn't been used. - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); -const { fileURLToPath } = require("url"); - -function getCopilotCliHome() { - const raw = process.env.COPILOT_HOME; - if (raw && raw.trim()) { - return raw.trim().replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".copilot"); -} - -function getCopilotCliSessionStateDir() { - return path.join(getCopilotCliHome(), "session-state"); -} - -/** - * VS Code workspace storage root. Platform-dependent. - */ -function getVscodeWorkspaceStorageDir() { - const home = os.homedir(); - switch (process.platform) { - case "darwin": - return path.join(home, "Library", "Application Support", "Code", "User", "workspaceStorage"); - case "win32": - return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), - "Code", "User", "workspaceStorage"); - default: // linux - return path.join(home, ".config", "Code", "User", "workspaceStorage"); - } -} - -function workspacePathFromUri(folder) { - if (typeof folder !== "string" || folder.length === 0) return null; - if (!folder.startsWith("file:")) return folder; - try { - return fileURLToPath(folder); - } catch { - try { - return decodeURIComponent(folder.replace(/^file:\/\//, "")); - } catch { - return folder.replace(/^file:\/\//, ""); - } - } -} - -function readWorkspacePathFromHashDir(hashPath) { - try { - const wsJson = JSON.parse(fs.readFileSync(path.join(hashPath, "workspace.json"), "utf8")); - return workspacePathFromUri(wsJson.folder || wsJson.workspace || ""); - } catch { - return null; - } -} - -/** - * Discover all chatSession JSON files across all VS Code workspaces. - * Returns array of { filePath, workspacePath } where workspacePath is resolved - * from the workspace.json in the hash directory. - */ -function listChatSessionFiles() { - const wsRoot = getVscodeWorkspaceStorageDir(); - if (!fs.existsSync(wsRoot)) return []; - const results = []; - - let hashDirs; - try { hashDirs = fs.readdirSync(wsRoot, { withFileTypes: true }); } catch { return []; } - - for (const hashDir of hashDirs) { - if (!hashDir.isDirectory()) continue; - const hashPath = path.join(wsRoot, hashDir.name); - const chatDir = path.join(hashPath, "chatSessions"); - if (!fs.existsSync(chatDir)) continue; - - const workspacePath = readWorkspacePathFromHashDir(hashPath); - - let files; - try { files = fs.readdirSync(chatDir, { withFileTypes: true }); } catch { continue; } - for (const f of files) { - if (f.isFile() && f.name.endsWith(".json")) { - results.push({ - filePath: path.join(chatDir, f.name), - workspacePath, - }); - } - } - } - return results; -} - -/** - * Collect all Copilot CLI event JSONL files under ~/.copilot/session-state/. - */ -function listCliEventFiles() { - const root = getCopilotCliSessionStateDir(); - if (!fs.existsSync(root)) return []; - const results = []; - - let sessionDirs; - try { sessionDirs = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; } - - for (const dir of sessionDirs) { - if (!dir.isDirectory()) continue; - const eventsFile = path.join(root, dir.name, "events.jsonl"); - if (fs.existsSync(eventsFile)) { - results.push({ filePath: eventsFile, sessionId: dir.name }); - } - } - return results; -} - -module.exports = { - getCopilotCliHome, - getCopilotCliSessionStateDir, - getVscodeWorkspaceStorageDir, - workspacePathFromUri, - readWorkspacePathFromHashDir, - listChatSessionFiles, - listCliEventFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js deleted file mode 100644 index 14671df0..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-import.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file copilot-import.js - * @description Bootstrap importer for GitHub Copilot sessions. Parses both - * Copilot Chat (VS Code extension JSON) and Copilot CLI (JSONL event logs) - * into the shared normalized session shape, reusing importSession(). - */ -const { parseChatSessionFile, parseCliEventFile } = require("./copilot-parser"); -const { listChatSessionFiles, listCliEventFiles } = require("./copilot-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Skip chat/CLI files unchanged since the last tick (FEA-1316); the persisted -// backing files additionally let a fresh process skip unchanged files on the -// cold-start boot import (FEA-1334). -const chatCache = createCatchupCache({ persistPath: ingestCachePath("copilot-chat") }); -const cliCache = createCatchupCache({ persistPath: ingestCachePath("copilot-cli") }); - -function importCopilotSession(dbModule, session) { - const result = importSession(dbModule, session); - try { - dbModule.stmts.setSessionHarness.run("copilot", session.sessionId, "copilot"); - } catch { /* non-fatal */ } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "copilot", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Copilot Chat + CLI session. Idempotent. - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - */ -async function importAllCopilotSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCopilotSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - const batch = []; - const chatParsed = []; - const cliParsed = []; - - // Discover both source sets up front so the orchestrator gets one honest - // total covering Chat (JSON) + CLI (JSONL) before parsing begins. - const chatFiles = listChatSessionFiles(); - const cliFiles = listCliEventFiles(); - if (onBegin) onBegin(chatFiles.length + cliFiles.length); - - // Copilot Chat (VS Code extension) — JSON files - for (const { filePath, workspacePath } of chatFiles) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = chatCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = parseChatSessionFile(filePath, workspacePath); - if (!session) { chatCache.markSeenWith(filePath, stat); skipped++; continue; } - batch.push(session); - chatParsed.push({ path: filePath, stat }); - } catch { errors++; } - } - - // Copilot CLI — JSONL event files - for (const { filePath, sessionId } of cliFiles) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = cliCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseCliEventFile(filePath, sessionId); - if (!session) { cliCache.markSeenWith(filePath, stat); skipped++; continue; } - batch.push(session); - cliParsed.push({ path: filePath, stat }); - } catch { errors++; } - } - - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of chatParsed) chatCache.markSeenWith(path, stat); - for (const { path, stat } of cliParsed) cliCache.markSeenWith(path, stat); - chatCache.pruneTo(chatFiles.map((f) => f.filePath)); - cliCache.pruneTo(cliFiles.map((f) => f.filePath)); - chatCache.flush(); - cliCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCopilotSessions, importCopilotSession }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js deleted file mode 100644 index af65cefa..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-parser.js +++ /dev/null @@ -1,493 +0,0 @@ -/** - * @file copilot-parser.js - * @description Parse GitHub Copilot session data into the normalized session - * object consumed by importSession(). Handles two formats: - * - * 1. Copilot Chat (VS Code extension): JSON files with conversation turns - * 2. Copilot CLI (`gh copilot`): JSONL event log files - * - * Both produce the same normalized shape so Copilot sessions render through - * the unchanged dashboard UI. - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { - extractErrorMessage, - toIso, - safeJson, - pushTurnDuration, -} = require("../agent-monitor-shared/parser-utils"); - -function hasRenderableContent(value, depth = 0) { - if (value == null || depth > 4) return false; - if (typeof value === "string") return value.trim().length > 0; - if (typeof value === "number" || typeof value === "boolean") return true; - if (Array.isArray(value)) return value.some((entry) => hasRenderableContent(entry, depth + 1)); - if (typeof value === "object") { - return Object.values(value).some((entry) => hasRenderableContent(entry, depth + 1)); - } - return false; -} - -function collectToolCalls(value, depth = 0, out = []) { - if (value == null || depth > 4) return out; - if (Array.isArray(value)) { - for (const entry of value) collectToolCalls(entry, depth + 1, out); - return out; - } - if (typeof value !== "object") return out; - - for (const key of ["toolCalls", "tool_calls", "functionCalls"]) { - const calls = value[key]; - if (Array.isArray(calls)) { - for (const call of calls) out.push(call); - } - } - - for (const key of ["message", "request", "prompt", "input", "response", "result", "reply", "output"]) { - collectToolCalls(value[key], depth + 1, out); - } - return out; -} - -function normalizeChatRequest(request, sessionData) { - if (!request || typeof request !== "object") return []; - - const requestTimestamp = - request.timestamp || - request.created_at || - request.createdAt || - request.requestDate || - request.message?.timestamp || - request.message?.createdAt || - sessionData.creationDate || - null; - const responseTimestamp = - request.responseTimestamp || - request.responseDate || - request.updatedAt || - request.response?.timestamp || - request.result?.timestamp || - sessionData.lastMessageDate || - requestTimestamp; - const userPayload = - request.message ?? - request.request ?? - request.prompt ?? - request.input; - const assistantPayload = - request.response ?? - request.result ?? - request.reply ?? - request.output; - const toolCalls = collectToolCalls(request); - const assistantError = extractErrorMessage( - request.responseError ?? - request.error ?? - request.result?.error ?? - request.response?.error, - ); - - const entries = []; - if ( - hasRenderableContent(userPayload) || - request.id != null || - request.requestId != null - ) { - entries.push({ - role: "user", - timestamp: requestTimestamp, - }); - } - - if ( - hasRenderableContent(assistantPayload) || - assistantError != null || - toolCalls.length > 0 || - request.response != null || - request.result != null || - request.reply != null || - request.output != null - ) { - entries.push({ - role: "assistant", - timestamp: responseTimestamp, - toolCalls, - thinking: Boolean( - request.thinking || - request.reasoning || - request.response?.thinking || - request.response?.reasoning || - request.result?.thinking || - request.result?.reasoning, - ), - error: assistantError, - }); - } - - return entries; -} - -function normalizeChatMessages(data) { - for (const key of ["messages", "turns", "history"]) { - const value = data[key]; - if (Array.isArray(value) && value.length > 0) return value; - } - - const requests = Array.isArray(data.requests) ? data.requests : []; - return requests.flatMap((request) => normalizeChatRequest(request, data)); -} - -/** - * Parse a Copilot Chat JSON session file (VS Code extension). - * Recent VS Code builds persist these as top-level metadata plus `requests[]`, - * while older shapes may store direct `messages[]` / `turns[]` arrays. - */ -function parseChatSessionFile(filePath, workspacePath) { - let data; - try { - data = JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { return null; } - - if (!data || typeof data !== "object") return null; - - const sessionId = data.sessionId || data.id || path.basename(filePath, ".json"); - - // P1 Fix: extract token usage from raw requests BEFORE normalization, - // since normalizeChatMessages reduces each request to {role, timestamp} - // and drops the original usage/response payloads. - const rawRequests = Array.isArray(data.requests) ? data.requests : []; - const requestTokenFields = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - for (const req of rawRequests) { - if (!req || typeof req !== "object") continue; - const usageInfo = - req.usage || req.tokenUsage || req.token_count || - req.response?.usage || req.result?.usage || null; - if (usageInfo && typeof usageInfo === "object") { - if (usageInfo.input_tokens != null) requestTokenFields.input += usageInfo.input_tokens; - if (usageInfo.output_tokens != null) requestTokenFields.output += usageInfo.output_tokens; - if (usageInfo.prompt_tokens != null) requestTokenFields.input += usageInfo.prompt_tokens; - if (usageInfo.completion_tokens != null) requestTokenFields.output += usageInfo.completion_tokens; - if (usageInfo.cache_read_tokens != null) requestTokenFields.cacheRead += usageInfo.cache_read_tokens; - if (usageInfo.cached_input_tokens != null) requestTokenFields.cacheRead += usageInfo.cached_input_tokens; - if (usageInfo.cache_write_tokens != null) requestTokenFields.cacheWrite += usageInfo.cache_write_tokens; - if (usageInfo.cache_creation_input_tokens != null) requestTokenFields.cacheWrite += usageInfo.cache_creation_input_tokens; - } - } - - const messages = normalizeChatMessages(data); - if (!Array.isArray(messages) || messages.length === 0) return null; - - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - const tokenFields = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for (const msg of messages) { - if (!msg || typeof msg !== "object") continue; - const ts = msg.timestamp || msg.created_at || msg.createdAt || msg.date || null; - const iso = noteTs(ts); - const role = msg.role || msg.author || msg.type || ""; - - if (role === "user" || role === "human") { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } else if (role === "assistant" || role === "copilot" || role === "bot") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Tool uses embedded in messages - const calls = - msg.toolCalls || - msg.tool_calls || - msg.functionCalls || - collectToolCalls(msg); - if (Array.isArray(calls)) { - for (const call of calls) { - if (!call) continue; - toolUses.push({ - name: call.name || call.function?.name || "copilot_tool", - timestamp: iso || firstTimestamp, - input: safeJson(call.arguments || call.input || call.parameters), - }); - } - } - - // Thinking blocks - if (msg.thinking || msg.reasoning) thinkingBlockCount++; - - const errorMessage = extractErrorMessage(msg.error); - if (errorMessage) { - apiErrors.push({ - type: "error", - message: errorMessage, - timestamp: iso, - }); - } - - // Token usage embedded in messages/requests - const usageInfo = - msg.usage || msg.tokenUsage || msg.token_count || msg.response?.usage || msg.result?.usage || null; - if (usageInfo && typeof usageInfo === "object") { - if (usageInfo.input_tokens != null) tokenFields.input += usageInfo.input_tokens; - if (usageInfo.output_tokens != null) tokenFields.output += usageInfo.output_tokens; - if (usageInfo.prompt_tokens != null) tokenFields.input += usageInfo.prompt_tokens; - if (usageInfo.completion_tokens != null) tokenFields.output += usageInfo.completion_tokens; - if (usageInfo.cache_read_tokens != null) tokenFields.cacheRead += usageInfo.cache_read_tokens; - if (usageInfo.cached_input_tokens != null) tokenFields.cacheRead += usageInfo.cached_input_tokens; - if (usageInfo.cache_write_tokens != null) tokenFields.cacheWrite += usageInfo.cache_write_tokens; - if (usageInfo.cache_creation_input_tokens != null) tokenFields.cacheWrite += usageInfo.cache_creation_input_tokens; - } - } - - // Merge request-level tokens (from raw requests before normalization) - // with message-level tokens. Use summation since each request is unique. - tokenFields.input += requestTokenFields.input; - tokenFields.output += requestTokenFields.output; - tokenFields.cacheRead += requestTokenFields.cacheRead; - tokenFields.cacheWrite += requestTokenFields.cacheWrite; - - // Token usage from top-level session data - const topUsage = data.usage || data.tokenUsage || data.token_count || null; - if (topUsage && typeof topUsage === "object") { - if (topUsage.input_tokens != null) tokenFields.input = Math.max(tokenFields.input, topUsage.input_tokens); - if (topUsage.output_tokens != null) tokenFields.output = Math.max(tokenFields.output, topUsage.output_tokens); - if (topUsage.prompt_tokens != null) tokenFields.input = Math.max(tokenFields.input, topUsage.prompt_tokens); - if (topUsage.completion_tokens != null) tokenFields.output = Math.max(tokenFields.output, topUsage.completion_tokens); - if (topUsage.cache_read_tokens != null) tokenFields.cacheRead = Math.max(tokenFields.cacheRead, topUsage.cache_read_tokens); - if (topUsage.cached_input_tokens != null) tokenFields.cacheRead = Math.max(tokenFields.cacheRead, topUsage.cached_input_tokens); - if (topUsage.cache_write_tokens != null) tokenFields.cacheWrite = Math.max(tokenFields.cacheWrite, topUsage.cache_write_tokens); - // P2 Fix: also map cache_creation_input_tokens to cacheWrite (alias) - if (topUsage.cache_creation_input_tokens != null) tokenFields.cacheWrite = Math.max(tokenFields.cacheWrite, topUsage.cache_creation_input_tokens); - } - - if (!firstTimestamp) { - // Fall back to file mtime - try { - const stat = fs.statSync(filePath); - firstTimestamp = stat.birthtime?.toISOString() || stat.mtime.toISOString(); - lastTimestamp = stat.mtime.toISOString(); - } catch { return null; } - } - - const model = data.model || data.modelId || null; - const cwd = workspacePath || data.cwd || data.workspaceFolder || null; - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Copilot Chat ${sessionId.slice(0, 8)}`; - - const tokensByModel = {}; - if (tokenFields.input || tokenFields.output || tokenFields.cacheRead || tokenFields.cacheWrite) { - const key = model || "copilot-default"; - tokensByModel[key] = { - input: tokenFields.input, - output: tokenFields.output, - cacheRead: tokenFields.cacheRead, - cacheWrite: tokenFields.cacheWrite, - }; - } - - return { - sessionId: `copilot-chat-${sessionId}`, - name: projectName, - cwd, - model, - version: null, - slug: null, - gitBranch: null, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "copilot", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -/** - * Parse a Copilot CLI events.jsonl file. - */ -async function parseCliEventFile(filePath, sessionId) { - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let tokenInput = 0; - let tokenOutput = 0; - let tokenCacheRead = 0; - let tokenCacheWrite = 0; - let tokenReasoning = 0; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { rec = JSON.parse(line); } catch { continue; } - if (!rec || typeof rec !== "object") continue; - - const ts = rec.timestamp || rec.ts || rec.created_at || null; - const iso = noteTs(ts); - const type = rec.type || rec.event || ""; - const payload = rec.payload || rec.data || rec; - - // Session metadata - if (type === "session_start" || type === "session_created" || type === "init") { - if (!cwd) cwd = payload.cwd || payload.workdir || null; - if (!version) version = payload.version || payload.cli_version || null; - if (!model) model = payload.model || null; - } - - // Messages - if (type === "user_message" || type === "user_input" || type === "prompt") { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } - if (type === "assistant_message" || type === "response" || type === "completion") { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Tool calls - if (type === "tool_call" || type === "function_call" || type === "command") { - toolUses.push({ - name: payload.name || payload.tool || payload.command || "copilot_tool", - timestamp: iso || firstTimestamp, - input: safeJson(payload.arguments || payload.input), - }); - } - - // Token usage - if (type === "usage" || type === "token_count" || type === "metrics") { - const info = payload.usage || payload; - if (info.input_tokens != null) tokenInput = info.input_tokens; - if (info.output_tokens != null) tokenOutput = info.output_tokens; - if (info.prompt_tokens != null) tokenInput = info.prompt_tokens; - if (info.completion_tokens != null) tokenOutput = info.completion_tokens; - if (info.cache_read_tokens != null) tokenCacheRead = info.cache_read_tokens; - if (info.cached_input_tokens != null) tokenCacheRead = info.cached_input_tokens; - if (info.cache_write_tokens != null) tokenCacheWrite = info.cache_write_tokens; - if (info.cache_creation_input_tokens != null) tokenCacheWrite = info.cache_creation_input_tokens; - if (info.reasoning_tokens != null) tokenReasoning = info.reasoning_tokens; - if (info.reasoning_output_tokens != null) tokenReasoning = info.reasoning_output_tokens; - if (payload.model) model = payload.model; - } - - // Errors - if (type === "error" || type === "api_error") { - apiErrors.push({ - type, - message: payload.message || payload.error || "Copilot CLI error", - timestamp: iso, - }); - } - - // Thinking - if (type === "reasoning" || type === "thinking") { - thinkingBlockCount++; - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (tokenInput || tokenOutput || tokenCacheRead || tokenCacheWrite || tokenReasoning) { - const key = model || "copilot-default"; - tokensByModel[key] = { - input: tokenInput, - output: tokenOutput + tokenReasoning, - cacheRead: tokenCacheRead, - cacheWrite: tokenCacheWrite, - }; - } - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Copilot CLI ${sessionId.slice(0, 8)}`; - - return { - sessionId: `copilot-cli-${sessionId}`, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch: null, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "copilot", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseChatSessionFile, parseCliEventFile }; diff --git a/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js b/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js deleted file mode 100644 index 1957b944..00000000 --- a/apps/desktop/scripts/agent-monitor-copilot/copilot-watcher.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @file copilot-watcher.js - * @description Live file watcher for GitHub Copilot sessions. Watches both: - * 1. VS Code workspace storage chatSessions/ directories for new/changed JSON - * 2. ~/.copilot/session-state/ for new/changed JSONL event logs - * - * Best-effort and non-fatal. - */ -const fs = require("fs"); -const path = require("path"); -const { - getCopilotCliSessionStateDir, - getVscodeWorkspaceStorageDir, - readWorkspacePathFromHashDir, -} = require("./copilot-home"); -const { parseChatSessionFile, parseCliEventFile } = require("./copilot-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; -const CHAT_SESSION_FILE_RE = /(^|[/\\])chatSessions[/\\][^/\\]+\.json$/i; - -let started = false; -let timer = null; -let retryTimers = []; -let catchupTimer = null; -let pending = new Map(); // filePath → { type: "chat"|"cli", meta } -const watchers = []; - -function processPending(broadcast) { - const entries = Array.from(pending.entries()); - pending = new Map(); - if (entries.length === 0) return; - - let dbModule; - let importCopilotSession; - try { - dbModule = require("../db"); - ({ importCopilotSession } = require("./copilot-import")); - } catch { return; } - - (async () => { - for (const [filePath, info] of entries) { - let session; - try { - if (info.type === "chat") { - session = parseChatSessionFile(filePath, info.workspacePath); - } else { - session = await parseCliEventFile(filePath, info.sessionId); - } - } catch { continue; } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCopilotSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { /* non-fatal */ } - } - })(); -} - -function scheduleProcess(broadcast, filePath, info) { - if (filePath) pending.set(filePath, info); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { processPending(broadcast); } catch { /* ignore */ } - }, DEBOUNCE_MS); -} - -function watchDir(root, broadcast, matchFn, infoFn) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!matchFn(filename)) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full, infoFn(full, filename)); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { return false; } -} - -function retryWatch(root, broadcast, matchFn, infoFn) { - if (watchDir(root, broadcast, matchFn, infoFn)) return; - let retryCount = 0; - const t = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(t); - retryTimers = retryTimers.filter((r) => r !== t); - return; - } - if (!fs.existsSync(root)) return; - if (watchDir(root, broadcast, matchFn, infoFn)) { - clearInterval(t); - retryTimers = retryTimers.filter((r) => r !== t); - runCatchupImport(broadcast); - } - }, RETRY_MS); - t.unref?.(); - retryTimers.push(t); -} - -function runCatchupImport(broadcast) { - let dbModule; - let importAllCopilotSessions; - try { - dbModule = require("../db"); - ({ importAllCopilotSessions } = require("./copilot-import")); - } catch { return; } - Promise.resolve() - .then(() => importAllCopilotSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "copilot"); - } - }) - .catch(() => {}); -} - -function startCopilotWatcher({ broadcast }) { - if (started) return; - started = true; - // Clear any stale retry timers from a previous lifecycle - for (const t of retryTimers) { clearInterval(t); } - retryTimers = []; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - - // Watch VS Code workspace storage for chat session JSON files - const wsRoot = getVscodeWorkspaceStorageDir(); - retryWatch( - wsRoot, - broadcast, - (filename) => CHAT_SESSION_FILE_RE.test(String(filename)), - (full) => { - const hashDir = path.dirname(path.dirname(full)); - return { type: "chat", workspacePath: readWorkspacePathFromHashDir(hashDir) }; - }, - ); - - // Watch Copilot CLI session-state for JSONL event files - const cliRoot = getCopilotCliSessionStateDir(); - retryWatch( - cliRoot, - broadcast, - (filename) => String(filename).endsWith(".jsonl"), - (full, filename) => ({ - type: "cli", - sessionId: path.basename(path.dirname(full)), - }), - ); -} - -function stopCopilotWatcher() { - if (timer) { clearTimeout(timer); timer = null; } - for (const t of retryTimers) { clearInterval(t); } - retryTimers = []; - if (catchupTimer) { clearInterval(catchupTimer); catchupTimer = null; } - for (const w of watchers) { try { w.close(); } catch { /* ignore */ } } - watchers.length = 0; - pending = new Map(); - started = false; -} - -module.exports = { startCopilotWatcher, stopCopilotWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js b/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js deleted file mode 100644 index d09d8d12..00000000 --- a/apps/desktop/scripts/agent-monitor-cost/cost-pricing.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * @file cost-pricing.js - * @description Canonical token-cost engine for the agent-monitor sidecar - * (CommonJS). Wraps `@pydantic/genai-prices` — the single source of truth for - * model rates — and converts the dashboard DB's per-harness token counts into - * the canonical `Usage` shape the library expects, then returns the library's - * computed price UNCHANGED. - * - * CLOSEDLOOP FEA-1431. Replaces the deleted hand-maintained pricing table + - * override pipeline (HOST_DEFAULT_PRICING / model_pricing-based calculateCost), - * which double-charged cached OpenAI tokens (the v1 overcharge bug — see the - * "input convention" note below). - * - * ── Core principle ────────────────────────────────────────────────────────── - * TRUST THE LIBRARY. This module never overrides, clamps, asserts, or rewrites - * any rate or price genai-prices returns. Its ONLY job is to feed correct - * INPUTS. If a rate is wrong, it is fixed upstream (or by bumping the pinned - * library version) — never patched locally. - * - * ── The input-token convention (the crux of correctness) ───────────────────── - * genai-prices treats `Usage.input_tokens` as the GRAND TOTAL prompt size - * (uncached + cache_read + cache_write); internally it derives - * uncached = input_tokens - cache_read_tokens - cache_write_tokens - * and throws if that goes negative. - * - * But the two providers report raw input differently, and the dashboard DB - * faithfully preserves whichever convention each harness ingested: - * • Anthropic (Claude Code harness): the API's `input_tokens` is FRESH / - * uncached; cache_read / cache_write are SEPARATE, additive fields. The DB - * stores `input` = fresh. So the grand total = input + cacheRead + cacheWrite. - * • OpenAI / others (Codex etc.): the API's `input_tokens` is the TOTAL prompt - * and cached tokens are a SUBSET of it. The DB stores `input` = total. So - * the grand total = input (cache must NOT be added — doing so double-charges - * the cached portion, which was the v1 overcharge bug). - * - * This is exactly what genai-prices' own `extractUsage` does (verified against - * the library: Anthropic raw {input:1000,cache_read:500,cache_write:300} → - * canonical input_tokens 1800; OpenAI raw {input:1000, cached:500} → canonical - * input_tokens 1000). We mirror that behavior here, keyed on the model's - * provider, so the conversion stays consistent with the library's source of - * truth rather than a local guess. A parity test asserts this Set matches the - * library's actual extractUsage summing behavior so drift is caught loudly. - */ -"use strict"; - -const { calcPrice, findProvider } = require("@pydantic/genai-prices"); - -/** - * Provider ids whose API reports `input_tokens` as FRESH (uncached) with cache - * counts as SEPARATE additive fields — so the genai-prices grand total is - * `input + cacheRead + cacheWrite`. Every other provider reports `input_tokens` - * as the TOTAL (cache is a subset), so `input` passes through unchanged. - * - * Anthropic is currently the only provider in genai-prices' data that uses the - * additive (separate cache_creation/cache_read) convention. This Set is - * verified against the library's own extractUsage in the parity test; if a - * future genai-prices version adds another additive-cache provider, that test - * fails so we update this Set deliberately. - */ -const CACHE_ADDITIVE_PROVIDERS = new Set(["anthropic"]); - -/** Coerce a possibly-null/undefined/string DB token count to a finite number. */ -function toCount(value) { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? n : 0; -} - -function notPriced(reason, provider = null) { - return { - priced: false, - provider, - costUsd: null, - inputCostUsd: null, - outputCostUsd: null, - reason, - }; -} - -/** - * Resolve the provider id for a model id, defensively (findProvider can throw - * on malformed input). Returns null when the model is unknown. - */ -function resolveProviderId(model) { - try { - const provider = findProvider({ modelId: model }); - return provider ? provider.id : null; - } catch { - return null; - } -} - -/** - * Build the canonical genai-prices `Usage` from the DB's per-harness counts, - * applying the provider-aware input convention described in the file header. - */ -function buildUsage(providerId, counts) { - const additive = providerId != null && CACHE_ADDITIVE_PROVIDERS.has(providerId); - return { - input_tokens: additive - ? counts.input + counts.cacheRead + counts.cacheWrite - : counts.input, - output_tokens: counts.output, - cache_read_tokens: counts.cacheRead, - cache_write_tokens: counts.cacheWrite, - }; -} - -/** - * Compute the USD cost for one (model, token-counts) row. - * - * @param {object} input - * @param {string} input.model Model id as stored in the DB. - * @param {number} input.inputTokens Provider-native input count (see header). - * @param {number} input.outputTokens - * @param {number} input.cacheReadTokens - * @param {number} input.cacheWriteTokens - * @param {Date} [input.timestamp] Optional historical pricing date. - * @returns {{ - * priced: boolean, - * provider: string|null, - * costUsd: number|null, - * inputCostUsd: number|null, - * outputCostUsd: number|null, - * reason: string|null, - * }} Library values are returned UNCHANGED (no rounding/clamping). `reason` is - * one of "unknown_model" | "no_match" | "compute_error" when not priced. - */ -function computeTokenCost(input) { - const model = input && typeof input.model === "string" ? input.model : ""; - if (model.length === 0) { - return notPriced("unknown_model"); - } - - const counts = { - input: toCount(input.inputTokens), - output: toCount(input.outputTokens), - cacheRead: toCount(input.cacheReadTokens), - cacheWrite: toCount(input.cacheWriteTokens), - }; - - const providerId = resolveProviderId(model); - const usage = buildUsage(providerId, counts); - const options = - input.timestamp instanceof Date ? { timestamp: input.timestamp } : undefined; - - let result; - try { - result = calcPrice(usage, model, options); - } catch { - // calcPrice throws on genuinely inconsistent input (e.g. negative uncached). - // Never crash the cost path — surface as not-priced so the caller can show - // "—" rather than a wrong number or an exception. - return notPriced("compute_error", providerId); - } - - if (!result) { - // Library found no matching model/provider → not priced. - return notPriced("no_match", providerId); - } - - return { - priced: true, - provider: (result.provider && result.provider.id) || providerId, - costUsd: result.total_price, - inputCostUsd: result.input_price, - outputCostUsd: result.output_price, - reason: null, - }; -} - -module.exports = { - computeTokenCost, - CACHE_ADDITIVE_PROVIDERS, - // Exported for the parity test only. - buildUsage, -}; diff --git a/apps/desktop/scripts/agent-monitor-cost/package.json b/apps/desktop/scripts/agent-monitor-cost/package.json deleted file mode 100644 index aaa0afce..00000000 --- a/apps/desktop/scripts/agent-monitor-cost/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "//": "Scopes this dir to CommonJS (parent apps/desktop is type:module). cost-pricing.js is build-time-copied into the generated agent-monitor server/lib (a CommonJS tree), mirroring scripts/agent-monitor-plans. Not part of the desktop ESM build.", - "type": "commonjs", - "private": true -} diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js deleted file mode 100644 index 9d7e71b6..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-home.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file cursor-home.js - * @description Centralized Cursor session path management. Resolves paths for - * Cursor's background agent JSONL transcripts stored under - * `~/.cursor/projects//agent-transcripts//`. - * - * Cursor also stores standard chat sessions in a SQLite database - * (`state.vscdb`) under VS Code workspace storage, but those are opaque - * key-value blobs — this module focuses on the structured agent transcripts - * that yield the same telemetry the dashboard expects. - */ -const path = require("path"); -const os = require("os"); -const fs = require("fs"); - -function getCursorHome() { - const raw = process.env.CURSOR_HOME; - if (raw && raw.trim()) { - return raw.trim().replace(/^~(?=\/)/, os.homedir()); - } - return path.join(os.homedir(), ".cursor"); -} - -function getCursorProjectsDir() { - return path.join(getCursorHome(), "projects"); -} - -/** - * Derive a stable session id from an agent transcript path. - * Cursor stores transcripts at: - * ~/.cursor/projects//agent-transcripts//.jsonl - * The session-id directory name is the canonical id. - */ -function sessionIdFromTranscriptPath(filePath) { - // The parent directory name is the session id - return path.basename(path.dirname(filePath)); -} - -/** - * Recursively collect every `*.jsonl` transcript file under the projects root. - * Cursor nests by project → agent-transcripts → session-id, but we walk - * generically. Depth-bounded and error-tolerant. - */ -function collectTranscriptFiles(root, { maxDepth = 8 } = {}) { - const out = []; - if (!root || !fs.existsSync(root)) return out; - const walk = (dir, depth) => { - if (depth > maxDepth) return; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - walk(full, depth + 1); - } else if (e.isFile() && e.name.endsWith(".jsonl")) { - out.push(full); - } - } - }; - walk(root, 0); - return out; -} - -/** - * All Cursor agent transcript files. - */ -function listAllTranscriptFiles() { - return collectTranscriptFiles(getCursorProjectsDir()); -} - -module.exports = { - getCursorHome, - getCursorProjectsDir, - sessionIdFromTranscriptPath, - collectTranscriptFiles, - listAllTranscriptFiles, -}; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js deleted file mode 100644 index 4cb3b2f0..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-import.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file cursor-import.js - * @description Bootstrap importer for Cursor agent sessions. Parses each - * Cursor agent transcript JSONL into the shared normalized session shape and - * reuses the existing importSession() so Cursor sessions land in the same - * sessions/agents/events/token_usage rows. The only Cursor-specific step is - * stamping `harness='cursor'` on the row afterwards. - */ -const { parseTranscriptFile } = require("./cursor-parser"); -const { listAllTranscriptFiles } = require("./cursor-home"); -const { importSession } = require("../../scripts/import-history"); -const { reactivateImportedSession } = require("../agent-monitor-shared/import-session-utils"); -const { createCatchupCache } = require("../agent-monitor-shared/catchup-cache"); -const { ingestCachePath } = require("../agent-monitor-shared/ingest-paths"); -const { stampSessionBillingMode } = require("../agent-monitor-shared/billing-stamp"); - -// Skip transcript files unchanged since the last tick to keep the 5 s catchup -// poll cheap (FEA-1316); the persisted backing file additionally lets a fresh -// process skip unchanged files on the cold-start boot import (FEA-1334). -const catchupCache = createCatchupCache({ persistPath: ingestCachePath("cursor") }); - -/** - * Import a single Cursor agent transcript file. - */ -function importCursorSession(dbModule, session) { - const result = importSession(dbModule, session); - try { - dbModule.stmts.setSessionHarness.run("cursor", session.sessionId, "cursor"); - } catch { /* non-fatal */ } - // FEA-1434: stamp the billing mode (idempotent + best-effort internally). - stampSessionBillingMode(dbModule.stmts, "cursor", session.sessionId); - const reactivated = reactivateImportedSession(dbModule, session); - return { sessionId: session.sessionId, result, reactivated }; -} - -/** - * Parse + import every discovered Cursor transcript file. Idempotent on repeat runs. - * - * @param {any} dbModule - * @param {{ signal?: AbortSignal, onBegin?: (total: number) => void, - * onProgress?: () => void }} [opts] - ingest-orchestrator progress - * hooks (FEA-1334). The watcher catchup tick calls this with no opts. - */ -async function importAllCursorSessions(dbModule, opts = {}) { - const onBegin = typeof opts.onBegin === "function" ? opts.onBegin : null; - const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null; - const signal = opts.signal || null; - const files = listAllTranscriptFiles(); - if (onBegin) onBegin(files.length); - let imported = 0; - let skipped = 0; - let errors = 0; - - const importBatch = dbModule.db.transaction((sessions) => { - for (const session of sessions) { - const { result, reactivated } = importCursorSession(dbModule, session); - if (result && result.skipped && !reactivated) skipped++; - else imported++; - } - }); - - const batch = []; - const parsedEntries = []; - for (const filePath of files) { - if (signal && signal.aborted) break; - if (onProgress) onProgress(); - const { unchanged, stat } = catchupCache.isUnchanged(filePath); - if (unchanged) { - skipped++; - continue; - } - try { - const session = await parseTranscriptFile(filePath); - if (!session) { - catchupCache.markSeenWith(filePath, stat); - skipped++; - continue; - } - batch.push(session); - parsedEntries.push({ path: filePath, stat }); - } catch { errors++; } - } - if (batch.length > 0) importBatch(batch); - for (const { path, stat } of parsedEntries) catchupCache.markSeenWith(path, stat); - catchupCache.pruneTo(files); - catchupCache.flush(); - - return { imported, skipped, errors }; -} - -module.exports = { importAllCursorSessions, importCursorSession }; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js deleted file mode 100644 index c40c8adf..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-parser.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * @file cursor-parser.js - * @description Parse a Cursor agent transcript JSONL file into the normalized - * session object consumed by importSession(). Cursor's background agent - * transcripts use a format similar to Codex rollouts — each line is a JSON - * record with a type, payload, and timestamp. The parser is intentionally - * tolerant of format drift across Cursor versions. - */ -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); -const { sessionIdFromTranscriptPath } = require("./cursor-home"); -const { toIso, safeJson, pushTurnDuration } = require("../agent-monitor-shared/parser-utils"); - -/** - * Parse a single Cursor agent transcript JSONL file. - * Returns null when the file carries no usable timestamp. - */ -async function parseTranscriptFile(filePath) { - const sessionId = sessionIdFromTranscriptPath(filePath); - - const rl = readline.createInterface({ - input: fs.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); - - let cwd = null; - let model = null; - let version = null; - let gitBranch = null; - let firstTimestamp = null; - let lastTimestamp = null; - let userMessageCount = 0; - let assistantMessageCount = 0; - const messageTimestamps = []; - const toolUses = []; - const turnDurations = []; - const apiErrors = []; - let thinkingBlockCount = 0; - const toolResultErrors = []; - let tokenInput = 0; - let tokenOutput = 0; - let tokenCacheRead = 0; - let tokenCacheWrite = 0; - let pendingTurnStartedAt = null; - - const noteTs = (raw) => { - const iso = toIso(raw); - if (!iso) return null; - if (!firstTimestamp || iso < firstTimestamp) firstTimestamp = iso; - if (!lastTimestamp || iso > lastTimestamp) lastTimestamp = iso; - return iso; - }; - - for await (const line of rl) { - if (!line.trim()) continue; - let rec; - try { rec = JSON.parse(line); } catch { continue; } - if (!rec || typeof rec !== "object") continue; - - const ts = rec.timestamp || rec.ts || rec.created_at || null; - const iso = noteTs(ts); - const type = rec.type || ""; - const payload = rec.payload || rec.data || rec; - - // Session metadata - if (type === "session_meta" || type === "session.created" || type === "session_start" || - (!type && (payload.cwd || payload.workdir || payload.workspace))) { - if (!cwd) cwd = payload.cwd || payload.workdir || payload.workspace || null; - if (!version) version = payload.version || payload.cli_version || payload.cursor_version || null; - if (!model) model = payload.model || null; - if (!gitBranch) { - if (typeof payload.git === "object" && payload.git) { - gitBranch = payload.git.branch || payload.git.ref || null; - } else if (payload.git_branch) { - gitBranch = payload.git_branch; - } - } - } - - // Model override (turn-level is authoritative) - if (type === "turn_context" || type === "turn.context" || type === "model_context") { - if (payload.model) model = payload.model; - if (!cwd && payload.cwd) cwd = payload.cwd; - } - - // User messages - if (type === "user_message" || type === "human_message" || - (type === "message" && (payload.role === "user" || payload.author === "user"))) { - userMessageCount++; - if (iso) pendingTurnStartedAt = iso; - } - - // Assistant messages - if (type === "assistant_message" || type === "agent_message" || - (type === "message" && (payload.role === "assistant" || payload.author === "assistant"))) { - assistantMessageCount++; - if (iso) messageTimestamps.push(iso); - pushTurnDuration(turnDurations, pendingTurnStartedAt, iso); - pendingTurnStartedAt = null; - } - - // Thinking/reasoning - if (type === "reasoning" || type === "thinking" || type === "agent_reasoning") { - thinkingBlockCount++; - } - - // Tool calls - if (type === "tool_call" || type === "function_call" || type === "tool_use" || - type === "command_execution" || type === "terminal_command") { - toolUses.push({ - name: payload.name || payload.tool_name || payload.command_name || "tool", - timestamp: iso || firstTimestamp, - input: safeJson(payload.arguments != null ? payload.arguments : payload.input), - }); - } - - // File edits (Cursor-specific) - if (type === "file_edit" || type === "apply_edit" || type === "code_edit") { - toolUses.push({ - name: "file_edit", - timestamp: iso || firstTimestamp, - input: payload.file || payload.path || null, - }); - } - - // Tool results with errors - if (type === "tool_result" || type === "tool_output" || type === "command_output") { - const isErr = payload.is_error === true || payload.success === false || - payload.exit_code > 0 || !!payload.error; - if (isErr) { - const content = typeof payload.output === "string" - ? payload.output.slice(0, 500) - : JSON.stringify(payload.error || payload.output || payload).slice(0, 500); - toolResultErrors.push({ content, timestamp: iso }); - } - } - - // Token usage - if (type === "token_count" || type === "usage" || type === "token_usage") { - const info = payload.usage || payload.token_count || payload; - if (info.input_tokens != null) tokenInput = info.input_tokens; - if (info.output_tokens != null) tokenOutput = info.output_tokens; - if (info.cache_read_tokens != null) tokenCacheRead = info.cache_read_tokens; - if (info.cached_input_tokens != null) tokenCacheRead = info.cached_input_tokens; - if (info.cache_write_tokens != null) tokenCacheWrite = info.cache_write_tokens; - if (info.cache_creation_input_tokens != null) tokenCacheWrite = info.cache_creation_input_tokens; - if (payload.model) model = payload.model; - } - - // Errors - if (type === "error" || type === "api_error" || type === "stream_error") { - apiErrors.push({ - type, - message: (typeof payload.message === "string" && payload.message) || - payload.error || "Cursor error", - timestamp: iso, - }); - } - } - - if (!firstTimestamp) return null; - - const tokensByModel = {}; - if (tokenInput || tokenOutput || tokenCacheRead || tokenCacheWrite) { - const key = model || "cursor-default"; - tokensByModel[key] = { - input: tokenInput, - output: tokenOutput, - cacheRead: tokenCacheRead, - cacheWrite: tokenCacheWrite, - }; - } - - let fileModifiedAt = null; - try { fileModifiedAt = fs.statSync(filePath).mtimeMs; } catch { /* non-fatal */ } - - const projectName = cwd ? path.basename(cwd) : `Cursor Session ${sessionId.slice(0, 8)}`; - - return { - sessionId, - name: projectName, - cwd, - model, - version, - slug: null, - gitBranch, - startedAt: firstTimestamp, - endedAt: lastTimestamp, - teams: [], - userMessages: userMessageCount, - assistantMessages: assistantMessageCount, - tokensByModel, - messageTimestamps, - toolUses, - compactions: [], - apiErrors, - fileModifiedAt, - turnDurations, - entrypoint: "cursor", - permissionMode: null, - thinkingBlockCount, - toolResultErrors, - usageExtras: { service_tiers: [], speeds: [], inference_geos: [] }, - }; -} - -module.exports = { parseTranscriptFile }; diff --git a/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js b/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js deleted file mode 100644 index 5e7010c5..00000000 --- a/apps/desktop/scripts/agent-monitor-cursor/cursor-watcher.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @file cursor-watcher.js - * @description Live file watcher for Cursor agent transcripts. Watches - * `~/.cursor/projects/` for new/changed JSONL transcript files and - * re-imports them into the dashboard on change. Best-effort and non-fatal. - */ -const fs = require("fs"); -const path = require("path"); -const { getCursorProjectsDir } = require("./cursor-home"); -const { parseTranscriptFile } = require("./cursor-parser"); -const { broadcastHarnessRows } = require("../agent-monitor-shared/harness-watcher-utils"); - -const DEBOUNCE_MS = 600; -const RETRY_MS = 4000; -const MAX_RETRY_ATTEMPTS = 75; // ~5 minutes at 4s intervals, then give up -const CATCHUP_POLL_MS = 5000; - -let started = false; -let timer = null; -let retryTimer = null; -let catchupTimer = null; -let pending = new Set(); -const watchers = []; - -function processPending(broadcast) { - const files = Array.from(pending); - pending = new Set(); - if (files.length === 0) return; - - let dbModule; - let importCursorSession; - try { - dbModule = require("../db"); - ({ importCursorSession } = require("./cursor-import")); - } catch { return; } - - (async () => { - for (const filePath of files) { - let session; - try { session = await parseTranscriptFile(filePath); } catch { continue; } - if (!session) continue; - try { - const before = dbModule.stmts.getSession.get(session.sessionId); - const apply = dbModule.db.transaction(() => { - importCursorSession(dbModule, session); - }); - apply(); - const row = dbModule.stmts.getSession.get(session.sessionId); - if (row) broadcast(before ? "session_updated" : "session_created", row); - const agent = dbModule.stmts.getAgent.get(`${session.sessionId}-main`); - if (agent) broadcast("agent_updated", agent); - } catch { /* non-fatal */ } - } - })(); -} - -function scheduleProcess(broadcast, filePath) { - if (filePath) pending.add(filePath); - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { processPending(broadcast); } catch { /* ignore */ } - }, DEBOUNCE_MS); -} - -function safeWatch({ root, broadcast }) { - try { - if (!fs.existsSync(root)) return false; - const w = fs.watch(root, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (!String(filename).endsWith(".jsonl")) return; - const full = path.join(root, filename); - scheduleProcess(broadcast, full); - }); - w.on("error", () => {}); - watchers.push(w); - return true; - } catch { return false; } -} - -function runCatchupImport(broadcast) { - let dbModule; - let importAllCursorSessions; - try { - dbModule = require("../db"); - ({ importAllCursorSessions } = require("./cursor-import")); - } catch { return; } - Promise.resolve() - .then(() => importAllCursorSessions(dbModule)) - .then(({ imported }) => { - if (imported > 0) { - broadcastHarnessRows(dbModule, broadcast, "cursor"); - } - }) - .catch(() => {}); -} - -function startCursorWatcher({ broadcast }) { - if (started) return; - started = true; - catchupTimer = setInterval(() => runCatchupImport(broadcast), CATCHUP_POLL_MS); - catchupTimer.unref?.(); - runCatchupImport(broadcast); - const root = getCursorProjectsDir(); - if (safeWatch({ root, broadcast })) return; - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - let retryCount = 0; - retryTimer = setInterval(() => { - if (++retryCount > MAX_RETRY_ATTEMPTS) { - clearInterval(retryTimer); - retryTimer = null; - return; - } - if (!fs.existsSync(root)) return; - if (safeWatch({ root, broadcast })) { - clearInterval(retryTimer); - retryTimer = null; - runCatchupImport(broadcast); - } - }, RETRY_MS); - retryTimer.unref?.(); -} - -function stopCursorWatcher() { - if (timer) { clearTimeout(timer); timer = null; } - if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } - if (catchupTimer) { clearInterval(catchupTimer); catchupTimer = null; } - for (const w of watchers) { try { w.close(); } catch { /* ignore */ } } - watchers.length = 0; - pending = new Set(); - started = false; -} - -module.exports = { startCursorWatcher, stopCursorWatcher }; diff --git a/apps/desktop/scripts/agent-monitor-embed/App.tsx b/apps/desktop/scripts/agent-monitor-embed/App.tsx deleted file mode 100644 index 1d241ec1..00000000 --- a/apps/desktop/scripts/agent-monitor-embed/App.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file App.tsx - * @description ClosedLoop-authored replacement for the upstream agent-monitor - * App router. Copied verbatim over `src/App.tsx` at build time by - * scripts/build-agent-monitor.mjs. - * - * This keeps our route contract explicit in-repo: we layer host-owned - * additions such as the gated Plans page on top of the pinned upstream base - * instead of editing the dependency in place. - */ - -import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { useCallback } from "react"; -import { Layout } from "./components/Layout"; -import { Dashboard } from "./pages/Dashboard"; -import { KanbanBoard } from "./pages/KanbanBoard"; -import { Sessions } from "./pages/Sessions"; -import { SessionDetail } from "./pages/SessionDetail"; -import { ActivityFeed } from "./pages/ActivityFeed"; -import { Analytics } from "./pages/Analytics"; -import { Workflows } from "./pages/Workflows"; -import { Settings } from "./pages/Settings"; -import { CcConfig } from "./pages/CcConfig"; -import { Run } from "./pages/Run"; -import { Plans } from "./pages/Plans"; -import { Packs } from "./pages/Packs"; -import { PackDetail } from "./pages/PackDetail"; -import { PullRequests } from "./pages/PullRequests"; -import { NotFound } from "./pages/NotFound"; -import { isPlanExtractionEnabled } from "./lib/closedloop-host-flags"; -import { useWebSocket } from "./hooks/useWebSocket"; -import { useNotifications } from "./hooks/useNotifications"; -import { eventBus } from "./lib/eventBus"; -import type { WSMessage } from "./lib/types"; - -export default function App() { - const onMessage = useCallback((msg: WSMessage) => { - eventBus.publish(msg); - }, []); - - const { connected } = useWebSocket(onMessage); - useNotifications(); - - return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - - - - ); -} diff --git a/apps/desktop/scripts/agent-monitor-embed/Layout.tsx b/apps/desktop/scripts/agent-monitor-embed/Layout.tsx deleted file mode 100644 index 93b48896..00000000 --- a/apps/desktop/scripts/agent-monitor-embed/Layout.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Layout.tsx - * @description ClosedLoop-authored replacement for the upstream agent-monitor - * Layout. Copied verbatim over `src/components/Layout.tsx` at build time by - * scripts/build-agent-monitor.mjs. - * - * The agent monitor ships embedded as an - -
- - -
- - - - - - -
- - -
-
-

Connection Status

-

Live gateway port, cloud connection, and endpoint configuration.

-
-
-
Gateway Port
-
Loading...
-
-
-
Cloud Connection
-
Loading...
-
Loading...
-
-
-
Remote Commands
-
Loading...
-
-
-
Connection Security
-
Loading...
-
Loading...
-
-
- -
-
-
Target ID
-
Loading...
-
-
-
Relay Origin
-
Loading...
-
-
-
API Origin
-
Loading...
-
-
-
-
- -
-
-
-
-

Cloud Relay

-

WebSocket relay for cloud command mode.

-
- - -
-
- - -
- - -
-

-
- -
- -
-

Local Gateway

-

REST API and browser origin for local mode.

-
- - -
-
- - -
-
- -
- -

Clears the dashboard database and re-imports every agent session from scratch. The progress bar at the top tracks the re-import.

-

-
-
-
-
- -
-

Saved Configs

-

Save the current origins and API key as a named profile, then switch between profiles instantly.

-
- - -
-
-
-
- - -
- - -
- -
-
-

Sandbox

-

Not set

-

Choose a base directory below.

-
-
-

Signing Keys

-

--

-

Loading authorization state...

-
-
-

Always Denied

-

7 paths

-

Built-in protections that override your sandbox.

-
-
- - -
-
-
-

Perimeter

-

Sandbox Directory

-

Gateway operations can only read or write inside this directory. Anything outside — or inside the always-denied list — is blocked.

-
-
- -
-
-

Allowed Root

-

- No directory selected -

- -
-
- -
-

- -
-

Always Denied

-
- ~/.ssh - ~/.gnupg - ~/.aws - ~/Library/Keychains - /etc - /bin - /sbin -
-
-
-
- - - - - -
- - -
-
-

Command-Line Tools

-

ClosedLoop runs these tools on your behalf. They're located automatically on your system. If a tool is missing or the wrong version is being used, set a custom path below.

-
-
-
-
-
- Claude Code -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- GitHub CLI -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Codex CLI -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Python 3 -
- Checking -
-
- -
- - -
-
-

-
-
-
-
- Git -
- Checking -
-
- -
- - -
-
-

-
-
-
- - -
-
-

Approval Policy

-

Controls when gateway operations require manual approval.

-
- - -
-
- -

Override the default tier for specific operations.

-
-
- - - -
- -
-
- -
-

Always-Allow Rules

-

Temporary bypass rules created when you click "Always Allow" on a pending request. Each rule matches the exact operation, method, path, and scope, and expires after 7 days.

-

None -- click "Always Allow" on a pending request to add rules here.

-
- -
- - -
-
-

Billing Admin Keys

-

- Organization Admin keys let ClosedLoop fetch what each vendor actually billed and - reconcile it against the local estimate. Keys are stored in your OS keychain and never - leave this device's main process — the dashboard only ever sees whether a key - exists, never the key itself. Saving verifies the key with a single billing-API call. -

- -
- - -
- - -
-

-
- -
- - -
- - -
-

-
-
- -
-

Drift Diagnostics

-

- Each row compares the local token-based estimate against the vendor's billed amount for - a day and model. Drift is informational — ClosedLoop never re-prices past sessions. - A positive drift means the local estimate ran higher than the vendor bill; negative - means lower. Use “Explain” on a flagged row for the most likely cause. -

- -
- - - - -
-

- -
- - - - - - - - - - - - - - - -
DayVendorModelLocalVendorDrift
No reconciliation data yet.
-
-
- -
-

Claude Code Usage (Anthropic estimate)

-

- Per-user Claude Code spend over the last 7 days, as reported by Anthropic's own - usage report. This is Anthropic's estimate, shown for reference - — it never replaces the local token-based ledger. Requires an Anthropic Admin - key above and a Team or Enterprise organization. -

- -
- - -
-

- -
- - - - - - - - - - - - - - -
UserTypeModelsInputOutputEst. cost
No Claude Code usage loaded yet.
-
-
- - -
- - -
-
-

Labs

-

Early access to experimental features and advanced controls. Flip a switch, see what happens.

-
-
- -
-
-
- -
- - - From 754688f336ed59a2d3d287976a2ad483449ac0f9 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 07:56:46 -0500 Subject: [PATCH 12/20] FEA-1550: Address Agent Monitor review feedback - Degrade Agent Monitor startup when PGlite runtime init fails instead of crashing desktop boot. - Drain queued PGlite writes before close and cap live hook event data at the existing 64KB import limit. - Strip cloud-synced event summaries and update sanitization coverage. - Refresh stale Agent Monitor docs and delete dead pull request E2E audit/contract files. Testing: Full desktop typecheck, lint, focused PGlite and sync sanitization tests, and full desktop test suite passed. Risks: Low; cloud sync now omits event summaries, which reduces relay detail but avoids leaking raw tool error text. --- CLAUDE.md | 7 +- apps/desktop/CLAUDE.md | 37 ++++------ .../src/main/agent-session-sync-service.ts | 1 + apps/desktop/src/main/app.ts | 44 +++++++---- .../src/main/claude-code-analytics-service.ts | 7 +- apps/desktop/src/main/database/pglite.ts | 10 ++- .../pull-requests.contract.test.mjs | 73 ------------------- .../audit/pull-requests.ui-audit.spec.ts | 48 ------------ .../agent-session-sync-sanitization.test.ts | 3 + .../pglite-agent-dashboard-database.test.ts | 71 ++++++++++++++++++ 10 files changed, 131 insertions(+), 170 deletions(-) delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs delete mode 100644 apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1ac436a0..d327adc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,8 @@ This rule does NOT apply to internal contracts that ship as a single unit with t - **[mistake]**: Never fabricate history in changelogs, commit messages, or comments. Do not claim code "replaces" or "fixes" a prior implementation unless that implementation verifiably exists in the codebase or git history. (context: changelog|hallucination|fabrication) - **[mistake]**: Before adding a fallback or recovery path, verify the triggering condition can actually occur. Dead fallbacks that read from files never written or variables never set create false confidence in error handling. (context: dead-code|fallback|unreachable) -### Agent Monitor & Sidecar Security -- **[mistake]**: Treat localhost sidecar routes and iframe messages as privileged surfaces. Mutating routes need origin/trusted-action guards, explicit target origins, and regression coverage. (context: agent-monitor|sidecar|security) +### Agent Monitor Security +- **[mistake]**: Treat localhost Agent Monitor hook/listener routes and renderer IPC as privileged surfaces. Mutating routes need explicit local-only binding, trusted-action guards where applicable, and regression coverage. (context: agent-monitor|listener|ipc|security) ### Process Spawning & Secrets - **[mistake]**: Keep large or sensitive data out of spawned argv/env. Use stdin or files for prompts, quote shell args, set approved cwd, and pass minimal child environments. (context: spawn|argv|env|secrets) @@ -54,9 +54,6 @@ This rule does NOT apply to internal contracts that ship as a single unit with t ### Boundary Validation - **[mistake]**: Runtime-validate gateway, IPC, and persisted payloads before path or file use. TypeScript casts and preload promise types do not protect missing or null fields. (context: validation|ipc|gateway) -### Generated Agent Monitor Runtime -- **[pattern]**: When generated sidecar overlays, snippets, or patch inputs change, update stamp/materialization inputs and verify generated output so stale assets or bypassed patches cannot ship. (context: agent-monitor|generated|build) - ### State & Lifecycle - **[mistake]**: Setting toggles must update persisted state and in-memory side effects together. Avoid one-way restart guards, stale tray state, or stale cloud presence. (context: settings|lifecycle|state) diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index 8ad2e396..83cd39f1 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -138,15 +138,12 @@ The Diagnostics tab shows the current in-memory gateway log plus a bounded previ ## Agent Monitor -> **Status (FEA-1504):** Agent Monitor has three boot modes. The default user -> experience is the legacy sidecar-backed dashboard (`agentMonitorEnabled=true`, -> `agentDashboardDesignSystemEnabled=false`): pnpm-managed upstream packages are -> materialized into `.generated/agent-monitor`, shipped unpacked, and rendered in -> the legacy iframe shell. The in-process design-system dashboard is a Labs -> opt-in only. When `agentDashboardDesignSystemEnabled` is not the literal -> boolean `true`, the main process must not load `src/main/database/`, -> `src/main/collectors/`, `AgentHookListener`, `desktop:db:*`, or the `app://` -> design renderer path. +> **Status (FEA-1550):** Agent Monitor is an in-process, PGlite-backed desktop +> feature. The legacy generated runtime tree, embedded web shell, and +> `agentDashboardDesignSystemEnabled` boot split have been removed. When +> `agentMonitorEnabled=false`, the main process must not start collectors, +> `AgentHookListener`, the `desktop:db:*` IPC handlers, cloud session sync, or +> dashboard-derived cost reads. The desktop app provides local Claude Code (and opt-in Codex) session/agent observability. It powers the **Dashboard** and the agent nav items (Sessions, @@ -155,18 +152,14 @@ is gated by the persisted `agentMonitorEnabled` desktop setting, which **defaults ON**; when disabled, the agent nav items are hidden and only the Gateway section remains. -- **Legacy sidecar (default):** `src/main/agent-monitor-sidecar.ts` launches the - generated Claude-Code-Agent-Monitor runtime tree. `build:agent-monitor` - materializes the tree from pnpm-managed upstream packages; package/stage logic - must keep `.generated/agent-monitor` available for default users. -- **Design-system runtime (Labs opt-in):** `src/main/agent-dashboard-design-system-runtime.ts` +- **Runtime:** `src/main/agent-dashboard-design-system-runtime.ts` is the only module allowed to import `src/main/database/`, `src/main/collectors/`, `AgentHookListener`, or register `desktop:db:*`. It is - reached only through `await import()` after boot mode resolves to - `design-system`. -- **Disabled mode:** `agentMonitorEnabled=false` starts no sidecar, no - design-system runtime, no dashboard-derived sync source, and no - dashboard-derived cost source. + reached only through `await import()` after the Agent Monitor setting is + enabled. +- **Disabled mode:** `agentMonitorEnabled=false` starts no design-system + runtime, no dashboard-derived sync source, and no dashboard-derived cost + source. - **Hook listener:** in design-system mode, `src/main/agent-monitor-listener.ts` binds `127.0.0.1:4820` in the main process and accepts the hook payload (`POST /api/hooks/event`, `GET /api/health`). Each event is @@ -186,9 +179,9 @@ Gateway section remains. - **Durable DB:** `app.getPath("userData")/agent-dashboard.pgdata` (PGlite, schema in `src/main/database/pglite.ts`). Persisted collector caches live under `/agent-dashboard-ingest/`. -- **UI:** a first-party React app in the main window (`src/renderer/`) — NO - iframe. The left sidebar drives the **Dashboard** + agent nav items; live - updates arrive via the `desktop:db:changed` IPC push after each write. +- **UI:** a first-party React app in the main window (`src/renderer/`). The + left sidebar drives the **Dashboard** + agent nav items; live updates arrive + via the `desktop:db:changed` IPC push after each write. - **Hooks are explicit opt-in (consent-bearing).** The user enables/disables tracking via the toggle → `src/main/agent-monitor-hooks.ts` writes/removes the hook entries in `~/.claude/settings.json` (and, opt-in, `~/.codex/hooks.json`). diff --git a/apps/desktop/src/main/agent-session-sync-service.ts b/apps/desktop/src/main/agent-session-sync-service.ts index 77847bb0..5b3eb850 100644 --- a/apps/desktop/src/main/agent-session-sync-service.ts +++ b/apps/desktop/src/main/agent-session-sync-service.ts @@ -735,6 +735,7 @@ export function sanitizeSessionForSync( })), events: session.events.map((event) => ({ ...event, + summary: null, data: stripDataContent(event.data), })), }; diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index ecce23a1..d793e0d2 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1442,22 +1442,34 @@ export class DesktopApplication { const { createAgentDashboardDesignSystemRuntime } = await import( "./agent-dashboard-design-system-runtime.js" ); - this.agentDashboardDesignSystem = - await createAgentDashboardDesignSystemRuntime({ - userDataPath: app.getPath("userData"), - getWindow: () => this.desktopWindow.getWindow(), - onTerminalFailure: (reason) => { - const notification = new Notification({ - title: "ClosedLoop Agent Monitor", - body: reason, - }); - notification.show(); - this.agentMonitorFailed = true; - this.agentMonitorFailureReason = reason; - this.refreshTrayState(); - }, - log: (scope, message) => gatewayLog.info(scope, message), - }); + try { + this.agentDashboardDesignSystem = + await createAgentDashboardDesignSystemRuntime({ + userDataPath: app.getPath("userData"), + getWindow: () => this.desktopWindow.getWindow(), + onTerminalFailure: (reason) => { + const notification = new Notification({ + title: "ClosedLoop Agent Monitor", + body: reason, + }); + notification.show(); + this.agentMonitorFailed = true; + this.agentMonitorFailureReason = reason; + this.refreshTrayState(); + }, + log: (scope, message) => gatewayLog.info(scope, message), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + gatewayLog.error( + "agent-monitor", + `failed to initialize Agent Monitor runtime: ${reason}`, + ); + this.agentMonitorFailed = true; + this.agentMonitorFailureReason = reason; + this.refreshTrayState(); + return null; + } this.agentDashboardDesignSystem.registerIpcHandlers(); } return this.agentDashboardDesignSystem; diff --git a/apps/desktop/src/main/claude-code-analytics-service.ts b/apps/desktop/src/main/claude-code-analytics-service.ts index 342f620e..46986ce7 100644 --- a/apps/desktop/src/main/claude-code-analytics-service.ts +++ b/apps/desktop/src/main/claude-code-analytics-service.ts @@ -17,10 +17,9 @@ * Lives entirely in desktop-main and depends on the Anthropic Admin key store * through a deliberately minimal reader interface (getKey/getStatus only — ISP): * it reads the key ONLY to construct the outbound client (which places it in - * request headers) and never logs it, never returns it over IPC, and never hands - * it to the sidecar. The per-user data it returns (emails) is org billing data - * that crosses IPC to the trusted host renderer only — never the sandboxed - * sidecar iframe. + * request headers), never logs it, and never returns it over IPC. The per-user + * data it returns (emails) is org billing data that crosses IPC to the trusted + * host renderer only. */ import type { AdminKeyStatus } from "./admin-key-store.js"; import { diff --git a/apps/desktop/src/main/database/pglite.ts b/apps/desktop/src/main/database/pglite.ts index 8d315980..4043d27c 100644 --- a/apps/desktop/src/main/database/pglite.ts +++ b/apps/desktop/src/main/database/pglite.ts @@ -293,7 +293,10 @@ export async function openPgliteAgentDatabase( }).processEvent, loadMeteredUsageRows: (cutoffIso: string) => loadPgliteMeteredUsageRows(db, cutoffIso), - close: () => db.close(), + close: async () => { + await queue.drain(); + await db.close(); + }, }; return database; @@ -310,6 +313,9 @@ function createWriteQueue() { ); return next; }, + drain(): Promise { + return tail; + }, }; } @@ -2074,7 +2080,7 @@ async function insertEvent( eventType, data.tool_name ?? null, summary ?? null, - safe(() => JSON.stringify(data)) ?? null, + importEventData(data), now, ], ); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs b/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs deleted file mode 100644 index 16ac0274..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/api-contract/pull-requests.contract.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -// Layer-1 HTTP contract test for the pull-requests router copied into the -// sidecar at apps/desktop/scripts/agent-monitor-pull-requests/pull-requests-route.js - -import { after, before, test } from "node:test"; -import assert from "node:assert/strict"; - -import { - makeTempDbPath, - reseedPacksAndSkills, - seedFixtureDb, -} from "../../helpers/seed-fixture-db.mjs"; -import { launchSidecar } from "../../helpers/launch-sidecar.mjs"; - -let sidecar; -let cleanupDb; -let baseUrl; - -before(async () => { - const tmp = makeTempDbPath(); - cleanupDb = tmp.cleanup; - seedFixtureDb(tmp.dbPath); - sidecar = await launchSidecar({ dbPath: tmp.dbPath }); - reseedPacksAndSkills(tmp.dbPath); - baseUrl = sidecar.baseUrl; -}); - -after(async () => { - await sidecar.stop(); - cleanupDb(); -}); - -test("GET /api/pull-requests returns the captured PR list with total/limit/offset", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body.pull_requests)); - assert.equal(body.total, 3); - const urls = body.pull_requests.map((p) => p.pr_url).sort(); - assert.deepEqual(urls, [ - "https://github.com/example/fixture-repo-a/pull/42", - "https://github.com/example/fixture-repo-a/pull/43", - "https://github.com/example/fixture-repo-c/pull/7", - ]); -}); - -test("each PR row exposes the fields the UI renders (repo, branch, harness, title)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const fortyTwo = body.pull_requests.find((p) => p.pr_number === 42); - assert.ok(fortyTwo, "PR #42 should be present"); - assert.equal(fortyTwo.repo_full_name, "example/fixture-repo-a"); - assert.equal(fortyTwo.branch_name, "fix/auth-bug"); - assert.equal(fortyTwo.harness, "claude"); - assert.match(fortyTwo.title, /Fix auth bug/); - assert.equal(fortyTwo.session_id, "fixture-sess-completed-1"); -}); - -test("PR list contains entries from multiple harnesses (claude + codex)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const harnesses = new Set(body.pull_requests.map((p) => p.harness)); - assert.ok(harnesses.has("claude")); - assert.ok(harnesses.has("codex")); -}); - -test("PR list contains entries from multiple repos (a + c)", async () => { - const res = await fetch(`${baseUrl}/api/pull-requests`); - const body = await res.json(); - const repos = new Set(body.pull_requests.map((p) => p.repo_full_name)); - assert.equal(repos.size, 2); - assert.ok(repos.has("example/fixture-repo-a")); - assert.ok(repos.has("example/fixture-repo-c")); -}); diff --git a/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts b/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts deleted file mode 100644 index 927fa919..00000000 --- a/apps/desktop/test-e2e/agent-monitor/specs/audit/pull-requests.ui-audit.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Layer-2 audit: every PullRequests tile in the manifest has its rendered text -// asserted against the formatted oracle value. Same table-driven shape as -// dashboard.ui-audit.spec.ts — shared logic in helpers/audit-tile.ts. The three -// PR summary tiles render via a data-testid (FEA-1437 Phase 3), so they bind by -// selector. - -import { expect, test } from "@playwright/test"; - -import { - loadManifest, - tilesForScreen, -} from "../../inventory/manifest-loader.mjs"; -// @ts-expect-error — .ts helper imported by Playwright's ts loader -import { assertTileMatchesOracle, tileSkip } from "../../helpers/audit-tile"; - -const manifest = loadManifest(); -const prTiles = tilesForScreen(manifest, "PullRequests"); - -test.describe("PullRequests tiles · UI audit (manifest-driven)", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/pull-requests"); - // Wait until the summary stats load (the value cells show "—" until the - // fetch resolves). Poll the Pull Requests tile until it is a digit. - await expect - .poll( - async () => { - const t = await page - .locator("[data-testid='audit-pr-stats-pull-requests']") - .innerText() - .catch(() => null); - return t; - }, - { timeout: 10_000 }, - ) - .toMatch(/\d/); - }); - - for (const row of prTiles) { - const { skip, suffix } = tileSkip(row); - const testFn = skip ? test.skip : test; - testFn( - `UI audit · ${row.id} matches oracle "${row.oracle}"${suffix}`, - async ({ page }) => { - await assertTileMatchesOracle(page, row); - }, - ); - } -}); diff --git a/apps/desktop/test/agent-session-sync-sanitization.test.ts b/apps/desktop/test/agent-session-sync-sanitization.test.ts index 2f532d57..50c2cd2b 100644 --- a/apps/desktop/test/agent-session-sync-sanitization.test.ts +++ b/apps/desktop/test/agent-session-sync-sanitization.test.ts @@ -37,6 +37,7 @@ test("agent-session sync sends all source sessions and sanitizes event content", assert.equal(sent.length, 1); assert.equal(sent[0].sessions[0].externalSessionId, "outside-sandbox"); assert.equal(sent[0].sessions[0].cwd, "/outside/sandbox/project"); + assert.equal(sent[0].sessions[0].events[0].summary, null); assert.deepEqual(sent[0].sessions[0].events[0].data, { exitCode: 0, nested: { safe: "preserved" }, @@ -47,6 +48,7 @@ test("sanitizeSessionForSync strips content-bearing keys recursively", () => { const sanitized = sanitizeSessionForSync(makeSyncedSession()); const data = sanitized.events[0].data as Record; + assert.equal(sanitized.events[0].summary, null); for (const key of [ "arguments", "command", "content", "new_string", "old_string", "output", "patch", "prompt", "reasoning", "stderr", "stdout", "text", @@ -70,6 +72,7 @@ function makeSyncedSession(): SyncedAgentSession { externalEventId: "event-1", eventType: "PostToolUse", toolName: "Bash", + summary: "raw tool error text", createdAt: "2026-06-08T12:01:00.000Z", data: { arguments: "shell command args", diff --git a/apps/desktop/test/pglite-agent-dashboard-database.test.ts b/apps/desktop/test/pglite-agent-dashboard-database.test.ts index f1fcc670..b6bbc2d8 100644 --- a/apps/desktop/test/pglite-agent-dashboard-database.test.ts +++ b/apps/desktop/test/pglite-agent-dashboard-database.test.ts @@ -52,6 +52,77 @@ test("PGlite dashboard database starts empty and fills from hook events", async } }); +test("PGlite close drains queued lifecycle writes before closing", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "agent-dashboard-pglite-")); + const dataDir = path.join(dir, "agent-dashboard.pgdata"); + const db = await openPgliteAgentDatabase({ + dataDir, + detectBillingMode: () => "metered_api", + now: () => "2026-06-07T12:00:00.000Z", + }); + + try { + const write = db.processEvent( + "SessionStart", + { + session_id: "close-drain-session", + cwd: "/workspace/project", + model: "claude-sonnet-4-5", + }, + "claude", + ); + await db.close(); + assert.equal(await write, true); + + const reopened = await openPgliteAgentDatabase({ + dataDir, + detectBillingMode: () => "metered_api", + }); + try { + assert.equal((await reopened.sessions.getById("close-drain-session"))?.id, "close-drain-session"); + } finally { + await reopened.close(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("PGlite live hook event data is capped before storage", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "agent-dashboard-pglite-")); + const dataDir = path.join(dir, "agent-dashboard.pgdata"); + const db = await openPgliteAgentDatabase({ + dataDir, + detectBillingMode: () => "metered_api", + now: () => "2026-06-07T12:00:00.000Z", + }); + + try { + await db.processEvent( + "SessionStart", + { + session_id: "large-event-session", + cwd: "/workspace/project", + tool_input: "x".repeat(70 * 1024), + }, + "claude", + ); + + const events = await db.events.getBySession("large-event-session"); + assert.deepEqual(JSON.parse(events[0].data ?? "{}"), { + truncated: true, + bytes: JSON.stringify({ + session_id: "large-event-session", + cwd: "/workspace/project", + tool_input: "x".repeat(70 * 1024), + }).length, + }); + } finally { + await db.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + test("PGlite workflow queries satisfy PostgreSQL GROUP BY rules", async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "agent-dashboard-pglite-")); const dataDir = path.join(dir, "agent-dashboard.pgdata"); From edf1b315eac5ab5969b251366d1fa92fb645ee39 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:00:07 -0500 Subject: [PATCH 13/20] FEA-1550: Port sidecar packs, plans, and PR features to first-party PGlite app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 10 new PGlite tables: pack_catalog, pack_catalog_history, pack_install_runs, agent_packs, skills, project_pack_associations, plans, plan_versions, pull_requests, pr_backfill_seen - Port catalog-store (seed, list, fetch result, install runs) from CJS/SQLite to TypeScript/PGlite with jsonb columns and positional params - Port pack-store (upsert, list, skills, usage, sessions) with PG-native json operators replacing json_extract, string_agg replacing GROUP_CONCAT - Port pack-scanner (filesystem discovery of 5 harness skill roots) and 6 catalog detector adapters (RTK, SuperClaude, voltagent, etc.) - Port install-orchestrator with security-hardened child env, 10min timeout, concurrency guard, and IPC streaming replacing SSE - Port catalog-fetcher (GitHub API stats with gh CLI + REST fallback) and catalog-contents (7 content type scrapers with 7-day TTL) - Port plan-store with extraction from hook events (ExitPlanMode, file writes, Codex plans) and startup backfill from ~/.claude/plans/ - Port pr-store with command-gated detection of gh pr create and startup transcript backfill with mtime caching - Add 25+ IPC handlers for catalog, packs, plans, and PR features - Add preload bridge wrappers and shared DTO types for all new features - Build full React UI: PacksCatalog with install/uninstall cards, InstallModal with streaming output, Sparkline, PlansView with version history and confirm/reject, PullRequestsView with stat pills - Wire startup lifecycle: catalog seed → pack scan → GitHub fetch - Schedule 24h recurring catalog fetch with cleanup on close - Expose constrained storeDb accessor (PgliteExecutor, not raw client) Testing: typecheck clean, lint clean, 26/26 tests pass Risks: First run creates empty catalog/inventory tables; data populates from filesystem scan and GitHub fetch on next boot cycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-dashboard-design-system-runtime.ts | 251 +++- apps/desktop/src/main/database/pglite.ts | 165 +- .../src/main/packs/catalog-contents.ts | 491 ++++++ .../desktop/src/main/packs/catalog-fetcher.ts | 441 ++++++ apps/desktop/src/main/packs/catalog-seed.json | 415 ++++++ apps/desktop/src/main/packs/catalog-store.ts | 607 ++++++++ .../src/main/packs/install-orchestrator.ts | 645 ++++++++ apps/desktop/src/main/packs/pack-scanner.ts | 1326 +++++++++++++++++ apps/desktop/src/main/packs/pack-store.ts | 646 ++++++++ apps/desktop/src/main/plans/plan-store.ts | 873 +++++++++++ .../desktop/src/main/preload-design-system.ts | 54 + .../src/main/pull-requests/pr-store.ts | 971 ++++++++++++ .../components/features/CatalogCard.tsx | 138 ++ .../components/features/CoreFeaturesView.tsx | 421 ++---- .../components/features/InstallModal.tsx | 168 +++ .../components/features/PacksCatalog.tsx | 574 +++++++ .../components/features/PlansView.tsx | 322 ++++ .../components/features/PullRequestsView.tsx | 281 ++++ .../components/features/Sparkline.tsx | 51 + .../src/renderer/types/desktop-api.d.ts | 46 + apps/desktop/src/shared/agent-db-contract.ts | 176 +++ 21 files changed, 8795 insertions(+), 267 deletions(-) create mode 100644 apps/desktop/src/main/packs/catalog-contents.ts create mode 100644 apps/desktop/src/main/packs/catalog-fetcher.ts create mode 100644 apps/desktop/src/main/packs/catalog-seed.json create mode 100644 apps/desktop/src/main/packs/catalog-store.ts create mode 100644 apps/desktop/src/main/packs/install-orchestrator.ts create mode 100644 apps/desktop/src/main/packs/pack-scanner.ts create mode 100644 apps/desktop/src/main/packs/pack-store.ts create mode 100644 apps/desktop/src/main/plans/plan-store.ts create mode 100644 apps/desktop/src/main/pull-requests/pr-store.ts create mode 100644 apps/desktop/src/renderer/components/features/CatalogCard.tsx create mode 100644 apps/desktop/src/renderer/components/features/InstallModal.tsx create mode 100644 apps/desktop/src/renderer/components/features/PacksCatalog.tsx create mode 100644 apps/desktop/src/renderer/components/features/PlansView.tsx create mode 100644 apps/desktop/src/renderer/components/features/PullRequestsView.tsx create mode 100644 apps/desktop/src/renderer/components/features/Sparkline.tsx diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index ac18843d..c72da80b 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -6,12 +6,22 @@ import type { MeteredUsageRow } from "./reconciliation-worker.js"; import type { AgentSessionSyncSource } from "./agent-session-sync-service.js"; import type { SessionPageRequest } from "../shared/agent-db-contract.js"; import { detectBillingMode } from "./billing-mode-detector.js"; +import { shell } from "electron"; import { openPgliteAgentDatabase, type PgliteAgentDatabase, } from "./database/pglite.js"; import { coerceDbId } from "./database/ipc-validation.js"; import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js"; +import * as catalogStore from "./packs/catalog-store.js"; +import * as packStore from "./packs/pack-store.js"; +import { runPackScanner } from "./packs/pack-scanner.js"; +import { streamRun } from "./packs/install-orchestrator.js"; +import { runCatalogFetch, scheduleCatalogFetch } from "./packs/catalog-fetcher.js"; +import { refreshCatalogContents } from "./packs/catalog-contents.js"; +import * as planStore from "./plans/plan-store.js"; +import * as prStore from "./pull-requests/pr-store.js"; +import catalogSeed from "./packs/catalog-seed.json" with { type: "json" }; const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ "desktop:db:get-sessions", @@ -37,6 +47,35 @@ const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ "desktop:db:get-subagents", "desktop:db:get-plans", "desktop:db:get-pull-requests", + // Catalog (FEA-1314) + "desktop:db:get-catalog", + "desktop:db:get-catalog-entry", + "desktop:db:get-catalog-readme", + "desktop:db:get-catalog-contents", + "desktop:db:get-catalog-history", + "desktop:db:catalog-install", + "desktop:db:catalog-uninstall", + "desktop:db:catalog-refresh", + "desktop:db:get-install-runs", + // Installed packs (FEA-1224) + "desktop:db:get-installed-packs", + "desktop:db:get-pack-detail", + "desktop:db:get-pack-sessions", + "desktop:db:get-all-skills", + "desktop:db:get-skill-invocations", + "desktop:db:get-recent-projects", + // Plans (FEA-1189) + "desktop:db:get-plans-list", + "desktop:db:get-plan", + "desktop:db:get-plan-versions", + "desktop:db:confirm-plan", + "desktop:db:reject-plan", + "desktop:db:open-plan", + // Pull Requests (FEA-1226) + "desktop:db:get-pr-stats", + "desktop:db:get-pr-sessions", + "desktop:db:get-pr-list", + "desktop:db:open-pr", ] as const; export interface AgentDashboardDesignSystemRuntimeOptions { @@ -96,6 +135,40 @@ export async function createAgentDashboardDesignSystemRuntime( "agent-dashboard", "PGlite runtime active for Agent Dashboard database", ); + + // The underlying PGlite client supports both query() and exec(), which + // the pack/plan/PR store modules need for full result access. + const dbForStores = agentDatabase.storeDb; + void (async () => { + try { + await catalogStore.upsertCatalogSeed(dbForStores, catalogSeed); + log("agent-dashboard", "Catalog seed applied"); + } catch (e) { + log("agent-dashboard", `Catalog seed failed: ${e instanceof Error ? e.message : String(e)}`); + } + try { + await runPackScanner(dbForStores); + log("agent-dashboard", "Pack scanner completed"); + } catch (e) { + log("agent-dashboard", `Pack scanner failed: ${e instanceof Error ? e.message : String(e)}`); + } + try { + const plansDir = path.join(process.env.CLAUDE_HOME || path.join(app.getPath("home"), ".claude"), "plans"); + const captures = planStore.extractPlansFromPlansDir(plansDir); + for (const c of captures) { + await planStore.upsertPlan(dbForStores, c); + } + if (captures.length > 0) log("agent-dashboard", `Backfilled ${captures.length} plans from ~/.claude/plans/`); + } catch (e) { + log("agent-dashboard", `Plan backfill failed: ${e instanceof Error ? e.message : String(e)}`); + } + })(); + + let catalogFetchTimer: ReturnType | null = null; + // Schedule async GitHub catalog fetch (best-effort, non-blocking) + void runCatalogFetch(dbForStores).catch(() => {}); + catalogFetchTimer = scheduleCatalogFetch(dbForStores); + let dbIpcRegistered = false; let closed = false; @@ -140,6 +213,10 @@ export async function createAgentDashboardDesignSystemRuntime( return; } closed = true; + if (catalogFetchTimer) { + clearInterval(catalogFetchTimer); + catalogFetchTimer = null; + } unregisterDesignSystemDbIpcHandlers(); await agentDatabase.close(); }, @@ -155,7 +232,7 @@ export async function createAgentDashboardDesignSystemRuntime( return; } dbIpcRegistered = true; - registerDesignSystemDbIpcHandlers(agentDatabase); + registerDesignSystemDbIpcHandlers(agentDatabase, options); }, loadMeteredUsageRows: (cutoffIso: string) => agentDatabase.loadMeteredUsageRows(cutoffIso), @@ -164,7 +241,10 @@ export async function createAgentDashboardDesignSystemRuntime( return runtime; } -function registerDesignSystemDbIpcHandlers(agentDatabase: PgliteAgentDatabase): void { +function registerDesignSystemDbIpcHandlers( + agentDatabase: PgliteAgentDatabase, + options: AgentDashboardDesignSystemRuntimeOptions, +): void { ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll()); ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) => @@ -275,6 +355,173 @@ function registerDesignSystemDbIpcHandlers(agentDatabase: PgliteAgentDatabase): ipcMain.handle("desktop:db:get-pull-requests", () => agentDatabase.dashboard.getPullRequests(), ); + + // --- Catalog (FEA-1314) --- + const dbForStores = agentDatabase.storeDb; + + ipcMain.handle("desktop:db:get-catalog", () => + catalogStore.listCatalog(dbForStores), + ); + + ipcMain.handle("desktop:db:get-catalog-entry", (_event, packId: unknown) => { + if (typeof packId !== "string") return null; + return catalogStore.getCatalog(dbForStores, packId); + }); + + ipcMain.handle("desktop:db:get-catalog-readme", async (_event, packId: unknown) => { + if (typeof packId !== "string") return null; + const entry = await catalogStore.getCatalog(dbForStores, packId); + return entry?.readme_excerpt ?? null; + }); + + ipcMain.handle("desktop:db:get-catalog-contents", async (_event, packId: unknown) => { + if (typeof packId !== "string") return null; + const entry = await catalogStore.getCatalog(dbForStores, packId); + if (!entry) return null; + await refreshCatalogContents(dbForStores, entry); + const refreshed = await catalogStore.getCatalog(dbForStores, packId); + return refreshed?.contents_cache ?? null; + }); + + ipcMain.handle("desktop:db:get-catalog-history", (_event, packId: unknown) => { + if (typeof packId !== "string") return []; + return catalogStore.listHistory(dbForStores, packId); + }); + + ipcMain.handle("desktop:db:catalog-install", async (_event, packId: unknown, harness: unknown, cwd?: unknown) => { + if (typeof packId !== "string" || typeof harness !== "string") return { started: false }; + return streamRun(dbForStores, { + pack_id: packId, + harness, + action: "install", + cwd: typeof cwd === "string" ? cwd : undefined, + getWindow: options.getWindow, + onComplete: () => void runPackScanner(dbForStores).catch(() => {}), + }); + }); + + ipcMain.handle("desktop:db:catalog-uninstall", async (_event, packId: unknown, harness: unknown) => { + if (typeof packId !== "string" || typeof harness !== "string") return { started: false }; + return streamRun(dbForStores, { + pack_id: packId, + harness, + action: "uninstall", + getWindow: options.getWindow, + onComplete: () => void runPackScanner(dbForStores).catch(() => {}), + }); + }); + + ipcMain.handle("desktop:db:catalog-refresh", () => + runCatalogFetch(dbForStores), + ); + + ipcMain.handle("desktop:db:get-install-runs", (_event, packId?: unknown) => + catalogStore.listInstallRuns(dbForStores, typeof packId === "string" ? { pack_id: packId } : {}), + ); + + // --- Installed Packs (FEA-1224) --- + + ipcMain.handle("desktop:db:get-installed-packs", () => + packStore.listPacks(dbForStores), + ); + + ipcMain.handle("desktop:db:get-pack-detail", (_event, packId: unknown) => { + if (typeof packId !== "string") return null; + return packStore.getPack(dbForStores, packId); + }); + + ipcMain.handle("desktop:db:get-pack-sessions", (_event, packId: unknown) => { + if (typeof packId !== "string") return []; + return packStore.listPackSessions(dbForStores, packId); + }); + + ipcMain.handle("desktop:db:get-all-skills", () => + packStore.listSkills(dbForStores), + ); + + ipcMain.handle("desktop:db:get-skill-invocations", (_event, name: unknown) => { + if (typeof name !== "string") return []; + return packStore.listSkillInvocations(dbForStores, name); + }); + + ipcMain.handle("desktop:db:get-recent-projects", async () => { + const result = await dbForStores.query<{ cwd: string }>( + `SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY started_at DESC LIMIT 20`, + ); + return result.rows.map((r) => r.cwd); + }); + + // --- Plans (FEA-1189) --- + + ipcMain.handle("desktop:db:get-plans-list", (_event, opts?: unknown) => { + const o = typeof opts === "object" && opts !== null ? opts as Record : {}; + return planStore.listPlans(dbForStores, { + sessionId: typeof o.sessionId === "string" ? o.sessionId : undefined, + needsConfirmation: typeof o.needsConfirmation === "boolean" ? o.needsConfirmation : undefined, + limit: typeof o.limit === "number" ? o.limit : undefined, + offset: typeof o.offset === "number" ? o.offset : undefined, + }); + }); + + ipcMain.handle("desktop:db:get-plan", (_event, id: unknown) => { + if (typeof id !== "string") return null; + return planStore.getPlan(dbForStores, id); + }); + + ipcMain.handle("desktop:db:get-plan-versions", (_event, planId: unknown) => { + if (typeof planId !== "string") return []; + return planStore.getPlanVersions(dbForStores, planId); + }); + + ipcMain.handle("desktop:db:confirm-plan", (_event, id: unknown) => { + if (typeof id !== "string") return; + return planStore.confirmPlan(dbForStores, id); + }); + + ipcMain.handle("desktop:db:reject-plan", (_event, id: unknown) => { + if (typeof id !== "string") return; + return planStore.rejectPlan(dbForStores, id); + }); + + ipcMain.handle("desktop:db:open-plan", async (_event, id: unknown, target?: unknown) => { + if (typeof id !== "string") return; + const plan = await planStore.getPlan(dbForStores, id); + if (!plan) return; + const filePath = String(target === "log" ? plan.source_log_path : plan.file_path); + if (filePath && filePath !== "null" && filePath !== "undefined") void shell.openPath(filePath); + }); + + // --- Pull Requests (FEA-1226) --- + + ipcMain.handle("desktop:db:get-pr-stats", () => + prStore.getPrStats(dbForStores), + ); + + ipcMain.handle("desktop:db:get-pr-sessions", (_event, opts?: unknown) => { + const o = typeof opts === "object" && opts !== null ? opts as Record : {}; + return prStore.listPrSessions(dbForStores, { + limit: typeof o.limit === "number" ? o.limit : undefined, + offset: typeof o.offset === "number" ? o.offset : undefined, + }); + }); + + ipcMain.handle("desktop:db:get-pr-list", (_event, opts?: unknown) => { + const o = typeof opts === "object" && opts !== null ? opts as Record : {}; + return prStore.listPullRequests(dbForStores, { + sessionId: typeof o.sessionId === "string" ? o.sessionId : undefined, + repo: typeof o.repo === "string" ? o.repo : undefined, + limit: typeof o.limit === "number" ? o.limit : undefined, + offset: typeof o.offset === "number" ? o.offset : undefined, + }); + }); + + ipcMain.handle("desktop:db:open-pr", async (_event, id: unknown) => { + if (typeof id !== "string") return; + const prs = await prStore.listPullRequests(dbForStores); + const pr = prs.find((p) => p.id === id); + const prUrl = pr?.pr_url; + if (typeof prUrl === "string") void shell.openExternal(prUrl); + }); } function unregisterDesignSystemDbIpcHandlers(): void { diff --git a/apps/desktop/src/main/database/pglite.ts b/apps/desktop/src/main/database/pglite.ts index 4043d27c..6137e52b 100644 --- a/apps/desktop/src/main/database/pglite.ts +++ b/apps/desktop/src/main/database/pglite.ts @@ -149,9 +149,169 @@ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_status_started_at ON sessions(status, started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_sessions_organization_id ON sessions(organization_id) WHERE organization_id IS NOT NULL; + +-- Pack catalog (FEA-1314 / PLN-657) +CREATE TABLE IF NOT EXISTS pack_catalog ( + pack_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + category TEXT, + github_url TEXT NOT NULL, + marketplace_url TEXT, + description TEXT, + description_live TEXT, + harnesses JSONB, + install_commands JSONB, + uninstall_commands JSONB, + install_notes TEXT, + placeholder_reason TEXT, + verified BOOLEAN NOT NULL DEFAULT FALSE, + readme_excerpt TEXT, + readme_fetched_at TEXT, + stars INTEGER, + forks INTEGER, + last_release TEXT, + last_fetched_at TEXT, + seed_version INTEGER NOT NULL DEFAULT 1, + pin_order INTEGER, + contents JSONB, + contents_cache JSONB, + contents_fetched_at TEXT, + detection_patterns JSONB, + harness_agnostic BOOLEAN NOT NULL DEFAULT FALSE, + project_scoped BOOLEAN NOT NULL DEFAULT FALSE, + single_install BOOLEAN NOT NULL DEFAULT FALSE, + post_install JSONB +); + +CREATE TABLE IF NOT EXISTS pack_catalog_history ( + pack_id TEXT NOT NULL, + fetched_at TEXT NOT NULL, + stars INTEGER, + forks INTEGER, + PRIMARY KEY (pack_id, fetched_at) +); + +CREATE TABLE IF NOT EXISTS pack_install_runs ( + id SERIAL PRIMARY KEY, + pack_id TEXT NOT NULL, + harness TEXT, + action TEXT NOT NULL, + command TEXT, + exit_code INTEGER, + started_at TEXT NOT NULL, + ended_at TEXT, + stdout_tail TEXT, + stderr_tail TEXT +); +CREATE INDEX IF NOT EXISTS idx_install_runs_pack ON pack_install_runs(pack_id); + +-- Pack inventory (FEA-1224) +CREATE TABLE IF NOT EXISTS agent_packs ( + pack_id TEXT NOT NULL, + harness TEXT NOT NULL, + install_path TEXT NOT NULL, + install_kind TEXT CHECK (install_kind IN ('symlink', 'directory')), + source_url TEXT, + version TEXT, + detected_at TEXT, + last_seen_at TEXT, + uninstalled_at TEXT, + PRIMARY KEY (pack_id, harness, install_path) +); +CREATE INDEX IF NOT EXISTS idx_agent_packs_pack ON agent_packs(pack_id); + +CREATE TABLE IF NOT EXISTS skills ( + skill_id TEXT PRIMARY KEY, + pack_id TEXT, + harness TEXT, + install_path TEXT, + name TEXT, + version TEXT, + description TEXT, + source_url TEXT, + detected_at TEXT, + last_seen_at TEXT, + uninstalled_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_skills_pack ON skills(pack_id); +CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name); + +CREATE TABLE IF NOT EXISTS project_pack_associations ( + project_path TEXT NOT NULL, + pack_id TEXT NOT NULL, + detected_at TEXT, + last_seen_at TEXT, + PRIMARY KEY (project_path, pack_id) +); + +-- Plans (FEA-1189 / PLN-613) +CREATE TABLE IF NOT EXISTS plans ( + id TEXT PRIMARY KEY, + title TEXT, + status TEXT NOT NULL DEFAULT 'active', + source TEXT, + capture_method TEXT, + harness TEXT, + created_from_session_id TEXT, + created_from_event_id TEXT, + plan_key TEXT, + file_path TEXT, + source_log_path TEXT, + needs_confirmation BOOLEAN NOT NULL DEFAULT FALSE, + confidence REAL NOT NULL DEFAULT 1.0, + sync_state TEXT, + metadata JSONB, + created_at TEXT, + updated_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_plans_session ON plans(created_from_session_id); +CREATE INDEX IF NOT EXISTS idx_plans_needs_confirmation ON plans(needs_confirmation) WHERE needs_confirmation = TRUE; +CREATE INDEX IF NOT EXISTS idx_plans_updated ON plans(updated_at DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_plans_session_key ON plans(created_from_session_id, plan_key) WHERE plan_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS plan_versions ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + content_markdown TEXT, + content_json JSONB, + content_sha256 TEXT, + author_type TEXT, + author_user_id TEXT, + source_session_id TEXT, + source_event_ref TEXT, + capture_method TEXT, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_plan_versions_plan ON plan_versions(plan_id); + +-- Pull Requests (FEA-1226) +CREATE TABLE IF NOT EXISTS pull_requests ( + id TEXT PRIMARY KEY, + session_id TEXT, + pr_url TEXT NOT NULL, + pr_number INTEGER, + repo_full_name TEXT, + branch_name TEXT, + head_sha TEXT, + title TEXT, + harness TEXT, + observed_at TEXT, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_pr_session ON pull_requests(session_id); +CREATE INDEX IF NOT EXISTS idx_pr_repo ON pull_requests(repo_full_name, pr_number); +CREATE INDEX IF NOT EXISTS idx_pr_observed ON pull_requests(observed_at DESC); + +CREATE TABLE IF NOT EXISTS pr_backfill_seen ( + session_id TEXT PRIMARY KEY, + file_path TEXT, + file_mtime_ms BIGINT, + scanned_at TEXT +); `; -interface PgliteExecutor { +export interface PgliteExecutor { exec(query: string): Promise; query = Record>( query: string, @@ -231,6 +391,8 @@ export interface PgliteAgentDatabase { getPullRequests(): Promise; }; getSummary(): Promise; + /** Constrained DB accessor for store modules (query + exec, no close/transaction). */ + storeDb: PgliteExecutor; run(sql: string, ...params: unknown[]): Promise; processEvent(hookType: string, data: HookData, harness: string): Promise; loadMeteredUsageRows(cutoffIso: string): Promise; @@ -267,6 +429,7 @@ export async function openPgliteAgentDatabase( const database: PgliteAgentDatabase = { backend: "pglite", connection: null, + storeDb: db, importer: createPgliteImporter(db, queue, tokenUsage, { detectBillingMode: options.detectBillingMode, now: nowFn, diff --git a/apps/desktop/src/main/packs/catalog-contents.ts b/apps/desktop/src/main/packs/catalog-contents.ts new file mode 100644 index 00000000..4d86bd93 --- /dev/null +++ b/apps/desktop/src/main/packs/catalog-contents.ts @@ -0,0 +1,491 @@ +/** + * @file catalog-contents.ts — fetch per-pack contents (skills, agents, + * commands, sub-plugins) from GitHub for the catalog detail view + * (FEA-1314 v3). Cached in pack_catalog.contents_cache via + * catalog-store.applyContentsFetch with a 7-day TTL. + * + * The per-pack `contents` JSON in catalog-seed.json declares how to scrape + * each pack: + * - github-skill-tree — list //SKILL.md + * - github-multi-skill-tree — multiple skill_paths (BMad) + * - github-flat-md — flat dir of .md files (SuperClaude commands) + * - github-nested-md — categories//.md (VoltAgent) + * - github-nested-skill-tree — /skills//SKILL.md (alirezarezvani) + * - claude-marketplace — read .claude-plugin/marketplace.json (closedloop) + * - github-claude-plugin — single marketplace plugin: walks commands/ + + * agents/ + skills/ if present under plugin_path + * (claude-plugins-official entries) + * - none — pack has no skill/command listing (RTK, claude-code-router) + * + * Returns [{ name, kind, description?, path? }]. kind is one of + * 'skill', 'command', 'agent', 'plugin'. + */ + +import { execFileSync } from "node:child_process"; +import https from "node:https"; + +import type { Results } from "@electric-sql/pglite"; + +import { resolveBinaryFromLoginShellSync } from "../../server/shell-path.js"; + +import { applyContentsFetch } from "./catalog-store.js"; + +const CONTENTS_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const REQUEST_TIMEOUT_MS = 8000; + +// ---------- types ---------- + +interface ParsedRepo { + owner: string; + repo: string; +} + +export type ContentItemKind = "skill" | "command" | "agent" | "plugin"; + +export interface ContentItem { + name: string; + kind: ContentItemKind; + description?: string | null; + path?: string; + category?: string; + /** Present on marketplace plugin items after per-plugin skill scrape. */ + skill_count?: number; + skills?: string[]; +} + +type ContentsType = + | "github-skill-tree" + | "github-multi-skill-tree" + | "github-flat-md" + | "github-nested-md" + | "github-nested-skill-tree" + | "claude-marketplace" + | "github-claude-plugin" + | "none"; + +interface ContentsSpec { + type?: ContentsType; + skills_path?: string; + skill_paths?: string[]; + skill_marker?: string; + md_path?: string; + kind?: ContentItemKind; + root_path?: string; + match_pattern?: string; + marketplace_repo?: string; + plugins_root?: string; + plugin_path?: string; +} + +export interface CatalogEntry { + pack_id: string; + github_url: string; + contents: ContentsSpec | null; + contents_fetched_at?: string | null; +} + +interface GitHubTreeEntry { + name: string; + path: string; + type: "file" | "dir"; + content?: string; +} + +interface MarketplaceManifest { + plugins?: Array<{ name: string; description?: string }>; +} + +type DbClient = { + query = Record>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +/** Database handle — async query interface. */ +type CatalogDb = DbClient; + +// ---------- low-level GitHub helpers ---------- + +function parseGithubUrl(url: string | null | undefined): ParsedRepo | null { + const m = String(url || "").match(/github\.com[/:]([^/]+)\/([^/?#.]+)/); + return m ? { owner: m[1], repo: m[2].replace(/\.git$/, "") } : null; +} + +function ghCliAvailable(): boolean { + const result = resolveBinaryFromLoginShellSync("gh"); + return result.source !== "fallback" && result.source !== "override_invalid"; +} + +function ghApi(endpoint: string): T | null { + try { + const out = execFileSync( + "gh", + ["api", endpoint, "--header", "Accept: application/vnd.github+json"], + { timeout: REQUEST_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }, + ); + return JSON.parse(out.toString("utf8")) as T; + } catch { + return null; + } +} + +function restApi(endpoint: string): Promise { + return new Promise((resolve) => { + const urlPath = endpoint.startsWith("/") ? endpoint : "/" + endpoint; + const req = https.get( + { + host: "api.github.com", + path: urlPath, + headers: { + "User-Agent": "closedloop-electron-agent-monitor", + Accept: "application/vnd.github+json", + }, + timeout: REQUEST_TIMEOUT_MS, + }, + (res) => { + if (res.statusCode !== 200) { + resolve(null); + res.resume(); + return; + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => (body += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(body) as T); + } catch { + resolve(null); + } + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + }); +} + +async function gh(endpoint: string): Promise { + if (ghCliAvailable()) { + const data = ghApi(endpoint); + if (data) return data; + } + return restApi(endpoint); +} + +function parseSkillFrontmatterFromBase64(b64: string | undefined): Record { + if (!b64) return {}; + try { + const content = Buffer.from(b64, "base64").toString("utf8"); + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return {}; + const fields: Record = {}; + for (const raw of m[1].split(/\r?\n/)) { + const line = raw.trim(); + const sep = line.indexOf(":"); + if (sep < 0) continue; + const key = line.slice(0, sep).trim().toLowerCase(); + let value = line.slice(sep + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + fields[key] = value; + } + return fields; + } catch { + return {}; + } +} + +// ---------- per-type fetchers ---------- + +async function fetchSkillTree( + owner: string, + repo: string, + skillsPath: string, + marker?: string, +): Promise { + const skillMarker = marker || "SKILL.md"; + const items = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(skillsPath)}`, + ); + if (!Array.isArray(items)) return []; + + const skills: ContentItem[] = []; + for (const entry of items) { + if (entry.type !== "dir") continue; + // Fetch the marker file's frontmatter (one extra API call per skill). + const file = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(entry.path)}/${skillMarker}`, + ); + if (!file || !file.content) { + skills.push({ name: entry.name, kind: "skill", path: entry.path }); + continue; + } + const meta = parseSkillFrontmatterFromBase64(file.content); + skills.push({ + name: meta.name || entry.name, + kind: "skill", + description: meta.description || null, + path: entry.path, + }); + } + return skills; +} + +async function fetchFlatMd( + owner: string, + repo: string, + mdPath: string, + kind?: ContentItemKind, +): Promise { + const items = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(mdPath)}`, + ); + if (!Array.isArray(items)) return []; + return items + .filter( + (e) => + e.type === "file" && + e.name.endsWith(".md") && + !e.name.toLowerCase().startsWith("readme"), + ) + .map((e) => ({ + name: e.name.replace(/\.md$/, ""), + kind: kind || "command", + path: e.path, + })); +} + +async function fetchNestedMd( + owner: string, + repo: string, + rootPath: string, + kind?: ContentItemKind, +): Promise { + const top = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(rootPath)}`, + ); + if (!Array.isArray(top)) return []; + + const items: ContentItem[] = []; + for (const cat of top) { + if (cat.type !== "dir") continue; + const inner = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(cat.path)}`, + ); + if (!Array.isArray(inner)) continue; + for (const file of inner) { + if (file.type !== "file") continue; + if (!file.name.endsWith(".md")) continue; + if (file.name.toLowerCase().startsWith("readme")) continue; + items.push({ + name: file.name.replace(/\.md$/, ""), + kind: kind || "agent", + category: cat.name, + path: file.path, + }); + } + } + return items; +} + +async function fetchMultiSkillTree( + owner: string, + repo: string, + paths: string[] | undefined, + marker?: string, +): Promise { + const out: ContentItem[] = []; + for (const p of paths || []) { + out.push(...(await fetchSkillTree(owner, repo, p, marker))); + } + return out; +} + +async function fetchClaudeMarketplace( + owner: string, + repo: string, + pluginsRoot?: string, +): Promise { + // Read .claude-plugin/marketplace.json for the canonical list. + const meta = await gh( + `repos/${owner}/${repo}/contents/.claude-plugin/marketplace.json`, + ); + if (meta && meta.content) { + try { + const parsed = JSON.parse( + Buffer.from(meta.content, "base64").toString("utf8"), + ) as MarketplaceManifest; + if (Array.isArray(parsed.plugins)) { + const items: ContentItem[] = parsed.plugins.map((p) => ({ + name: p.name, + kind: "plugin" as const, + description: p.description || null, + })); + // If a plugins_root is declared, walk each plugin's skills/ dir for a + // richer breakdown. Best-effort; bail if API budget is exhausted. + if (pluginsRoot) { + for (const item of items) { + try { + const skills = await fetchSkillTree( + owner, + repo, + `${pluginsRoot}/${item.name}/skills`, + ); + item.skill_count = skills.length; + item.skills = skills.map((s) => s.name); + } catch { + /* per-plugin scrape best-effort */ + } + } + } + return items; + } + } catch { + /* malformed marketplace.json — fall through */ + } + } + return []; +} + +/** + * Walk a single claude-plugins-official-style plugin dir for its commands, + * agents, and skills. Plugins in that marketplace have a mixed layout — + * some have only commands/, some commands + agents, some skills, etc. — + * so a single dispatch needs to handle whichever subset is present. + */ +async function fetchClaudePlugin( + owner: string, + repo: string, + pluginPath: string, +): Promise { + const items: ContentItem[] = []; + const top = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(pluginPath)}`, + ); + if (!Array.isArray(top)) return items; + const hasDir = (name: string): boolean => + top.some((e) => e.type === "dir" && e.name === name); + + // commands/.md + if (hasDir("commands")) { + items.push( + ...(await fetchFlatMd(owner, repo, `${pluginPath}/commands`, "command")), + ); + } + // agents/.md + if (hasDir("agents")) { + items.push( + ...(await fetchFlatMd(owner, repo, `${pluginPath}/agents`, "agent")), + ); + } + // skills//SKILL.md + if (hasDir("skills")) { + items.push(...(await fetchSkillTree(owner, repo, `${pluginPath}/skills`))); + } + return items; +} + +async function fetchNestedSkillTree( + owner: string, + repo: string, + _matchPattern?: string, +): Promise { + // Simple two-level walk: /skills//SKILL.md + const top = await gh(`repos/${owner}/${repo}/contents`); + if (!Array.isArray(top)) return []; + + const items: ContentItem[] = []; + for (const teamDir of top) { + if (teamDir.type !== "dir") continue; + if (teamDir.name.startsWith(".")) continue; + const teamSkillsPath = `${teamDir.path}/skills`; + const innerTry = await gh( + `repos/${owner}/${repo}/contents/${encodeURI(teamSkillsPath)}`, + ); + if (!Array.isArray(innerTry)) continue; + for (const skillDir of innerTry) { + if (skillDir.type !== "dir") continue; + items.push({ + name: skillDir.name, + kind: "skill", + category: teamDir.name, + path: skillDir.path, + }); + } + // Soft cap to avoid blowing the API budget for huge multi-team repos. + if (items.length >= 100) break; + } + return items; +} + +// ---------- dispatch ---------- + +export async function fetchContents(entry: CatalogEntry): Promise { + const contents = entry.contents; + if (!contents || !contents.type) return []; + const parsed = parseGithubUrl(entry.github_url); + if (!parsed) return []; + const { owner, repo } = parsed; + + switch (contents.type) { + case "github-skill-tree": + return fetchSkillTree(owner, repo, contents.skills_path!, contents.skill_marker); + case "github-multi-skill-tree": + return fetchMultiSkillTree(owner, repo, contents.skill_paths, contents.skill_marker); + case "github-flat-md": + return fetchFlatMd(owner, repo, contents.md_path!, contents.kind); + case "github-nested-md": + return fetchNestedMd(owner, repo, contents.root_path!, contents.kind); + case "github-nested-skill-tree": + return fetchNestedSkillTree(owner, repo, contents.match_pattern); + case "claude-marketplace": { + const repoFromContents = contents.marketplace_repo + ? parseGithubUrl(`https://github.com/${contents.marketplace_repo}`) + : null; + const mkO = repoFromContents ? repoFromContents.owner : owner; + const mkR = repoFromContents ? repoFromContents.repo : repo; + return fetchClaudeMarketplace(mkO, mkR, contents.plugins_root); + } + case "github-claude-plugin": { + const repoFromContents = contents.marketplace_repo + ? parseGithubUrl(`https://github.com/${contents.marketplace_repo}`) + : null; + const pluginO = repoFromContents ? repoFromContents.owner : owner; + const pluginR = repoFromContents ? repoFromContents.repo : repo; + return fetchClaudePlugin(pluginO, pluginR, contents.plugin_path!); + } + case "none": + return []; + default: + return []; + } +} + +/** + * Top-level entry. Fetches contents for one catalog entry, applies to the + * cache table, and returns the items. + */ +export async function refreshCatalogContents( + db: CatalogDb, + catalogEntry: CatalogEntry, +): Promise { + const items = await fetchContents(catalogEntry); + await applyContentsFetch(db, { pack_id: catalogEntry.pack_id, items }); + return items; +} + +export function isContentsFresh(entry: CatalogEntry): boolean { + if (!entry.contents_fetched_at) return false; + return ( + Date.now() - new Date(entry.contents_fetched_at).getTime() < CONTENTS_TTL_MS + ); +} diff --git a/apps/desktop/src/main/packs/catalog-fetcher.ts b/apps/desktop/src/main/packs/catalog-fetcher.ts new file mode 100644 index 00000000..5e3602b1 --- /dev/null +++ b/apps/desktop/src/main/packs/catalog-fetcher.ts @@ -0,0 +1,441 @@ +/** + * @file catalog-fetcher.ts + * @description Periodic GitHub stats fetcher for the Agent Pack Catalog + * (FEA-1314 / PLN-657). Walks every row in `pack_catalog`, hits the GitHub + * REST API for stars/forks/description/latest-release, and writes the + * result via catalog-store.applyFetchResult — which both updates live + * fields on pack_catalog and appends a row to pack_catalog_history (for the + * sparkline). + * + * Auth preference: + * 1. Local `gh` CLI (`gh api repos//`) — uses the user's + * `gh auth login`, zero credentials in the sidecar + * 2. Unauthenticated REST (`https://api.github.com/repos/...`) — 60 + * req/hr; the catalog has ~10 packs / 24h so this is comfortable + * + * Best-effort: a single pack's 404/rate-limit logs a warning and continues; + * the run as a whole always returns a summary. + */ + +import { execFileSync } from "node:child_process"; +import https from "node:https"; + +import type { Results } from "@electric-sql/pglite"; + +import { resolveBinaryFromLoginShellSync } from "../../server/shell-path.js"; +import { applyFetchResult } from "./catalog-store.js"; + +// FEA-1314 v6: marketplace sub-plugins (e.g. code-review, context7) live as +// folders inside a parent marketplace repo. The default per-repo fetch +// (stars + description) writes the MARKETPLACE's stars/description to every +// sub-plugin row, making all of them look identical (e.g. 5 plugins all +// showing "21.3k stars · Official, Anthropic-managed directory of..."). +// For these, we instead fetch each plugin's own .claude-plugin/plugin.json +// for its plugin-specific name/description/version, and leave stars NULL — +// the marketplace's star count doesn't represent the individual plugin. + +const REQUEST_TIMEOUT_MS = 5000; +const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +const USER_AGENT = "closedloop-electron-agent-monitor"; + +interface ParsedRepo { + owner: string; + repo: string; +} + +interface FetchSummary { + started_at: string; + ended_at?: string; + used_gh_cli: boolean; + succeeded: number; + failed: number; + skipped: number; +} + +interface CatalogRow extends Record { + pack_id: string; + github_url: string; + contents: string | null; +} + +interface ContentsJson { + type?: string; + marketplace_repo?: string; + plugin_path?: string; +} + +interface GitHubRepoResponse { + stargazers_count?: number; + forks_count?: number; + description?: string; +} + +interface GitHubReleaseResponse { + tag_name?: string; + name?: string; +} + +interface PluginManifest { + description?: string; + version?: string; +} + +type DbClient = { + query = Record>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +type CatalogDb = DbClient; + +export function ghCliAvailable(): boolean { + const result = resolveBinaryFromLoginShellSync("gh"); + return result.source !== "fallback" && result.source !== "override_invalid"; +} + +/** + * Parse owner/repo out of a github URL. + * https://github.com/owner/repo -> { owner, repo } + * https://github.com/owner/repo.git -> { owner, repo } + * https://github.com/owner/repo/tree/main -> { owner, repo } + */ +export function parseGithubUrl(url: string | null | undefined): ParsedRepo | null { + if (typeof url !== "string") return null; + const m = url.match(/github\.com[/:]([^/]+)\/([^/?#.]+)/); + if (!m) return null; + return { owner: m[1], repo: m[2].replace(/\.git$/, "") }; +} + +function ghFetch(owner: string, repo: string): GitHubRepoResponse | null { + try { + const out = execFileSync( + "gh", + ["api", `repos/${owner}/${repo}`, "--header", "Accept: application/vnd.github+json"], + { timeout: REQUEST_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] }, + ); + return JSON.parse(out.toString("utf8")) as GitHubRepoResponse; + } catch { + return null; + } +} + +function ghFetchLatestRelease(owner: string, repo: string): string | null { + try { + const out = execFileSync( + "gh", + ["api", `repos/${owner}/${repo}/releases/latest`], + { timeout: REQUEST_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }, + ); + const parsed = JSON.parse(out.toString("utf8")) as GitHubReleaseResponse; + return parsed && (parsed.tag_name || parsed.name) + ? (parsed.tag_name || parsed.name)! + : null; + } catch { + return null; + } +} + +function httpGetJson(urlPath: string): Promise { + return new Promise((resolve) => { + const req = https.get( + { + host: "api.github.com", + path: urlPath, + headers: { + "User-Agent": USER_AGENT, + Accept: "application/vnd.github+json", + }, + timeout: REQUEST_TIMEOUT_MS, + }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer | string) => { + body += chunk; + }); + res.on("end", () => { + if (res.statusCode === 200) { + try { + resolve(JSON.parse(body) as T); + } catch { + resolve(null); + } + } else { + resolve(null); + } + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + }); +} + +async function restFetch(owner: string, repo: string): Promise { + return httpGetJson(`/repos/${owner}/${repo}`); +} + +async function restFetchLatestRelease(owner: string, repo: string): Promise { + const parsed = await httpGetJson( + `/repos/${owner}/${repo}/releases/latest`, + ); + return parsed && (parsed.tag_name || parsed.name) + ? (parsed.tag_name || parsed.name)! + : null; +} + +/** + * Fetch a marketplace sub-plugin's .claude-plugin/plugin.json from the + * parent marketplace repo. Returns the parsed JSON or null. Used to source + * plugin-specific description + version for catalog entries whose + * `contents.type === 'github-claude-plugin'`. + */ +function ghFetchPluginManifest( + owner: string, + repo: string, + pluginPath: string, +): PluginManifest | null { + try { + const out = execFileSync( + "gh", + [ + "api", + `repos/${owner}/${repo}/contents/${encodeURI(pluginPath)}/.claude-plugin/plugin.json`, + "--header", + "Accept: application/vnd.github.raw", + ], + { timeout: REQUEST_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }, + ); + return JSON.parse(out.toString("utf8")) as PluginManifest; + } catch { + return null; + } +} + +function restFetchPluginManifest( + owner: string, + repo: string, + pluginPath: string, +): Promise { + return new Promise((resolve) => { + const req = https.get( + { + host: "api.github.com", + path: `/repos/${owner}/${repo}/contents/${encodeURI(pluginPath)}/.claude-plugin/plugin.json`, + headers: { + "User-Agent": USER_AGENT, + Accept: "application/vnd.github.raw", + }, + timeout: REQUEST_TIMEOUT_MS, + }, + (res) => { + if (res.statusCode !== 200) { + resolve(null); + res.resume(); + return; + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", (c: string) => (body += c)); + res.on("end", () => { + try { + resolve(JSON.parse(body) as PluginManifest); + } catch { + resolve(null); + } + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { + req.destroy(); + resolve(null); + }); + }); +} + +async function fetchPluginManifest( + owner: string, + repo: string, + pluginPath: string, + useGh: boolean, +): Promise { + if (useGh) { + const m = ghFetchPluginManifest(owner, repo, pluginPath); + if (m) return m; + } + return restFetchPluginManifest(owner, repo, pluginPath); +} + +function parseJsonField(value: string | null | undefined): ContentsJson | null { + if (!value) return null; + try { + return JSON.parse(value) as ContentsJson; + } catch { + return null; + } +} + +/** + * Fetch stats for every pack in pack_catalog and apply them via the store. + * Best-effort per pack; returns a summary. + */ +export async function runCatalogFetch(db: CatalogDb): Promise { + const summary: FetchSummary = { + started_at: new Date().toISOString(), + used_gh_cli: ghCliAvailable(), + succeeded: 0, + failed: 0, + skipped: 0, + }; + + let rows: CatalogRow[]; + try { + const result = await db.query( + "SELECT pack_id, github_url, contents FROM pack_catalog", + ); + rows = result.rows; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[catalog-fetcher] cannot read pack_catalog:", msg); + return summary; + } + + for (const row of rows) { + const parsed = parseGithubUrl(row.github_url); + if (!parsed) { + summary.skipped += 1; + continue; + } + + const contents = parseJsonField(row.contents); + const isMarketplaceSubPlugin = contents && contents.type === "github-claude-plugin"; + + if (isMarketplaceSubPlugin) { + // FEA-1314 v7: marketplace sub-plugin path. Always fetch the manifest + // from contents.marketplace_repo (where the install lives). For + // stars/forks: if `github_url` parses to the SAME repo as + // contents.marketplace_repo, then github_url is a subdirectory of the + // marketplace and has no independent star count — leave stars null + // (avoids the v5 "all 4 cards show 21.3k" bug). If github_url is a + // DIFFERENT repo (e.g. context7's github_url=upstash/context7, + // marketplace_repo=anthropics/claude-plugins-official), that's a true + // upstream and we fetch its real star count. + const mkRepo = contents.marketplace_repo + ? parseGithubUrl(`https://github.com/${contents.marketplace_repo}`) + : null; + const manifestOwner = mkRepo ? mkRepo.owner : parsed.owner; + const manifestRepo = mkRepo ? mkRepo.repo : parsed.repo; + const manifest = await fetchPluginManifest( + manifestOwner, + manifestRepo, + contents.plugin_path!, + summary.used_gh_cli, + ); + if (!manifest) { + summary.failed += 1; + continue; + } + + // Decide if github_url points to a distinct upstream. + const sameAsMarketplace = + mkRepo && parsed.owner === mkRepo.owner && parsed.repo === mkRepo.repo; + let stars: number | null = null; + let forks: number | null = null; + let release: string | null = null; + if (!sameAsMarketplace) { + let repo: GitHubRepoResponse | null = summary.used_gh_cli + ? ghFetch(parsed.owner, parsed.repo) + : null; + if (!repo) repo = await restFetch(parsed.owner, parsed.repo); + if (repo) { + stars = repo.stargazers_count == null ? null : repo.stargazers_count; + forks = repo.forks_count == null ? null : repo.forks_count; + release = summary.used_gh_cli + ? ghFetchLatestRelease(parsed.owner, parsed.repo) + : await restFetchLatestRelease(parsed.owner, parsed.repo); + } + } + + try { + await applyFetchResult(db, { + pack_id: row.pack_id, + stars, + forks, + description: manifest.description || null, + last_release: manifest.version || release || null, + }); + summary.succeeded += 1; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn( + `[catalog-fetcher] applyFetchResult failed for ${row.pack_id}:`, + msg, + ); + summary.failed += 1; + } + continue; + } + + // Default path: standalone repo — fetch its stars + description. + let repo: GitHubRepoResponse | null = null; + let release: string | null = null; + if (summary.used_gh_cli) { + repo = ghFetch(parsed.owner, parsed.repo); + if (repo) release = ghFetchLatestRelease(parsed.owner, parsed.repo); + } + if (!repo) { + repo = await restFetch(parsed.owner, parsed.repo); + if (repo) release = await restFetchLatestRelease(parsed.owner, parsed.repo); + } + if (!repo) { + summary.failed += 1; + continue; + } + try { + await applyFetchResult(db, { + pack_id: row.pack_id, + stars: repo.stargazers_count == null ? null : repo.stargazers_count, + forks: repo.forks_count == null ? null : repo.forks_count, + description: repo.description || null, + last_release: release, + }); + summary.succeeded += 1; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn( + `[catalog-fetcher] applyFetchResult failed for ${row.pack_id}:`, + msg, + ); + summary.failed += 1; + } + } + + summary.ended_at = new Date().toISOString(); + return summary; +} + +/** + * Schedule recurring fetches. Returns a handle that can be cleared. Called + * by startup code; the immediate run happens via runCatalogFetch. + */ +export function scheduleCatalogFetch( + db: CatalogDb, + intervalMs: number = DEFAULT_INTERVAL_MS, +): ReturnType { + const handle = setInterval(() => { + runCatalogFetch(db).catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[catalog-fetcher] scheduled run failed:", msg); + }); + }, intervalMs); + if (typeof handle.unref === "function") handle.unref(); + return handle; +} diff --git a/apps/desktop/src/main/packs/catalog-seed.json b/apps/desktop/src/main/packs/catalog-seed.json new file mode 100644 index 00000000..1e726d78 --- /dev/null +++ b/apps/desktop/src/main/packs/catalog-seed.json @@ -0,0 +1,415 @@ +{ + "seed_version": 11, + "_note": "Seed v11 (2026-05-21): gstack install commands made idempotent — `git clone` is now gated by `test -d` so re-running the install on a partially-installed pack tops it off (e.g. adds codex symlinks when only claude was previously set up) instead of erroring out on a re-clone of an existing directory. v10's flags preserved.", + "packs": [ + { + "pack_id": "closedloop-ai", + "display_name": "ClosedLoop Agent Plugins", + "category": "framework", + "github_url": "https://github.com/closedloop-ai/claude-plugins", + "description": "ClosedLoop's own pack: plan, code, code-review, judges, platform, self-learning. The same agents that ship inside the ClosedLoop platform — multi-agent orchestration with planning, review, and self-learning loops.", + "harnesses": ["claude"], + "install_commands": { + "claude": "curl -fsSL https://raw.githubusercontent.com/closedloop-ai/claude-plugins/main/install.sh | bash" + }, + "uninstall_commands": { + "claude": "for p in code code-review judges platform self-learning; do claude plugin uninstall \"$p@closedloop-ai\" --scope user 2>/dev/null || true; done" + }, + "install_notes": "Installs the five runtime plugins (code, code-review, judges, platform, self-learning) into Claude Code at user scope. The bootstrap plugin is available in the marketplace but excluded from the default install.", + "verified": true, + "pin_order": 0, + "detection_patterns": [ + "/.claude/plugins/cache/closedloop-ai/", + "/.claude/plugins/marketplaces/closedloop-ai/", + "closedloop-ai/claude-plugins" + ], + "contents": { + "type": "claude-marketplace", + "marketplace_repo": "closedloop-ai/claude-plugins", + "plugins_root": "plugins" + } + }, + { + "pack_id": "rtk", + "display_name": "RTK (Rust Token Killer)", + "category": "tooling", + "github_url": "https://github.com/rtk-ai/rtk", + "description": "High-performance CLI proxy that filters and summarizes tool output before it reaches your LLM context. Wraps git/cargo/npm/cargo/docker/kubectl/etc. — saves 60-90% on tokens for common dev commands. Works with any AI coding harness.", + "harnesses": ["claude", "codex"], + "harness_agnostic": true, + "install_commands": { + "claude": "brew tap rtk-ai/tap && brew install rtk", + "codex": "brew tap rtk-ai/tap && brew install rtk" + }, + "uninstall_commands": { + "claude": "brew uninstall rtk && brew untap rtk-ai/tap 2>/dev/null || true", + "codex": "brew uninstall rtk && brew untap rtk-ai/tap 2>/dev/null || true" + }, + "install_notes": "Requires Homebrew. After install, prefix dev commands with `rtk` (e.g. `rtk git status`, `rtk cargo test`). For agents to use it automatically, configure the harness's AGENTS.md or CLAUDE.md to prefer `rtk`-prefixed commands. The `untap` step is best-effort — fails silently if other taps share the formula.", + "verified": true, + "contents": { + "type": "none", + "reason": "RTK is a CLI proxy binary, not a skill collection. Installs the `rtk` binary which wraps existing dev tools." + } + }, + { + "pack_id": "code-review", + "display_name": "Code Review (Anthropic)", + "category": "methodology", + "github_url": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/code-review", + "marketplace_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Anthropic's official multi-agent code-review plugin: runs specialized review agents with confidence-based scoring to filter false positives. Direct alternative/complement to closedloop's code-review for teams that want a vendor-shipped baseline.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install code-review@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall code-review@claude-plugins-official --scope user" + }, + "install_notes": "Hand-picked from Anthropic's claude-plugins-official marketplace. Installable headlessly via the Claude CLI.", + "verified": true, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/code-review/" + ], + "contents": { + "type": "github-claude-plugin", + "marketplace_repo": "anthropics/claude-plugins-official", + "plugin_path": "plugins/code-review" + } + }, + { + "pack_id": "code-modernization", + "display_name": "Code Modernization (Anthropic)", + "category": "methodology", + "github_url": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/code-modernization", + "marketplace_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Structured workflow for modernizing legacy codebases (COBOL, legacy Java/C++, monolith web apps). Phases: assess → map → extract-rules → reimagine → transform. Ships with agents that own each phase.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install code-modernization@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall code-modernization@claude-plugins-official --scope user" + }, + "install_notes": "Hand-picked from Anthropic's claude-plugins-official marketplace.", + "verified": true, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/code-modernization/" + ], + "contents": { + "type": "github-claude-plugin", + "marketplace_repo": "anthropics/claude-plugins-official", + "plugin_path": "plugins/code-modernization" + } + }, + { + "pack_id": "claude-code-setup", + "display_name": "Claude Code Setup (Anthropic)", + "category": "methodology", + "github_url": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-code-setup", + "marketplace_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Analyzes your codebase and recommends tailored Claude Code automations — hooks, skills, MCP servers, subagents. Excellent first-time-experience companion for new projects.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install claude-code-setup@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall claude-code-setup@claude-plugins-official --scope user" + }, + "install_notes": "Hand-picked from Anthropic's claude-plugins-official marketplace. After install, invoke its setup skill from a fresh project to get a recommended automation stack.", + "verified": true, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/claude-code-setup/" + ], + "contents": { + "type": "github-claude-plugin", + "marketplace_repo": "anthropics/claude-plugins-official", + "plugin_path": "plugins/claude-code-setup" + } + }, + { + "pack_id": "claude-md-management", + "display_name": "CLAUDE.md Management (Anthropic)", + "category": "methodology", + "github_url": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-md-management", + "marketplace_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Tools to maintain and improve CLAUDE.md files — audit quality, capture session learnings, keep project memory current. Pairs well with closedloop / gstack which rely on CLAUDE.md for context.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install claude-md-management@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall claude-md-management@claude-plugins-official --scope user" + }, + "install_notes": "Hand-picked from Anthropic's claude-plugins-official marketplace.", + "verified": true, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/claude-md-management/" + ], + "contents": { + "type": "github-claude-plugin", + "marketplace_repo": "anthropics/claude-plugins-official", + "plugin_path": "plugins/claude-md-management" + } + }, + { + "pack_id": "context7", + "display_name": "Context7 (Upstash)", + "category": "tooling", + "github_url": "https://github.com/upstash/context7", + "marketplace_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Upstash Context7 MCP server for up-to-date documentation lookup. Pulls version-specific docs + code examples directly from source repositories into your LLM context. Community-managed plugin in Anthropic's official marketplace.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install context7@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall context7@claude-plugins-official --scope user" + }, + "install_notes": "MCP server — adds documentation-lookup tools rather than slash commands or agents. Hand-picked from Anthropic's claude-plugins-official marketplace; sourced from external_plugins/context7.", + "verified": true, + "post_install": { + "title": "Optional: add an Upstash API key for higher rate limits", + "body": "Context7 works out of the box on the free tier. If you want higher request limits or private docs, sign up at Upstash and add the API key to your Claude Code config.", + "url": "https://context7.com/dashboard", + "required": false + }, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/context7/", + "mcp__context7__", + "upstash/context7" + ], + "contents": { + "type": "github-claude-plugin", + "marketplace_repo": "anthropics/claude-plugins-official", + "plugin_path": "external_plugins/context7" + } + }, + { + "pack_id": "superpowers", + "display_name": "Superpowers", + "category": "methodology", + "github_url": "https://github.com/obra/superpowers", + "description": "Methodology + skills framework: brainstorming, TDD, systematic debugging, subagent dev, code review. The single most-installed Claude Code plugin on Anthropic's official marketplace.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install superpowers@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall superpowers@claude-plugins-official --scope user" + }, + "install_notes": "Installs from Anthropic's official Claude Code plugin marketplace (claude-plugins-official). Codex install is interactive via the Codex /plugins UI — not scripted from CLI.", + "verified": true, + "detection_patterns": [ + "/.claude/plugins/cache/claude-plugins-official/superpowers/", + "/obra/superpowers" + ], + "contents": { + "type": "github-skill-tree", + "skills_path": "skills", + "skill_marker": "SKILL.md" + } + }, + { + "pack_id": "compound-engineering", + "display_name": "Compound Engineering", + "category": "methodology", + "github_url": "https://github.com/EveryInc/compound-engineering-plugin", + "description": "Official Compound Engineering plugin for Claude Code, Codex, Cursor, and more. Multi-harness agentic workflows from Every Inc.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin install compound-engineering@claude-plugins-official --scope user" + }, + "uninstall_commands": { + "claude": "claude plugin uninstall compound-engineering@claude-plugins-official --scope user" + }, + "install_notes": "Installable from Anthropic's official Claude Code plugin marketplace. Codex/Cursor installs follow each harness's plugin format — see README.", + "verified": true, + "detection_patterns": [ + "/compound-engineering-plugin/", + "/.claude/plugins/cache/claude-plugins-official/compound-engineering/" + ] + }, + { + "pack_id": "gstack", + "display_name": "gstack", + "category": "workflow-pack", + "github_url": "https://github.com/garrytan/gstack", + "description": "Garry Tan's opinionated Claude Code setup: 23 tools that serve as CEO/Designer/Eng Manager/Release Manager/Doc Engineer/QA. Includes /autoplan, /ship, /qa, /cso, /review, /codex.", + "harnesses": ["claude", "codex"], + "single_install": true, + "install_commands": { + "claude": "(test -d ~/.claude/skills/gstack || git clone https://github.com/garrytan/gstack ~/.claude/skills/gstack) && cd ~/.claude/skills/gstack && ./setup", + "codex": "(test -d ~/.claude/skills/gstack || git clone https://github.com/garrytan/gstack ~/.claude/skills/gstack) && cd ~/.claude/skills/gstack && ./setup && ./setup --host codex" + }, + "uninstall_commands": { + "claude": "rm -rf ~/.claude/skills/gstack && find ~/.claude/skills -maxdepth 2 -name SKILL.md -lname '*gstack*' -print0 2>/dev/null | xargs -0 -I {} dirname {} | xargs -I {} rm -rf {}", + "codex": "find ~/.codex/skills -maxdepth 1 -name 'gstack*' -exec rm -rf {} +" + }, + "verified": true, + "install_notes": "After the clone lands, gstack's `./setup` is INTERACTIVE — it prompts for several confirmations. The catalog runs the clone + setup, but you may need to answer prompts in the install modal's output pane. Codex install runs `./setup --host codex` after the Claude side. NOTE: gstack's setup creates per-skill symlinks at `~/.claude/skills//SKILL.md` pointing into the gstack repo (NOT under `~/.claude/skills/gstack/`); the uninstall command removes both the gstack repo AND every dangling symlink it leaves behind.", + "detection_patterns": [ + "/.claude/skills/gstack", + "/.codex/skills/gstack", + "/.gstack/", + "gstack-builder-profile", + "gstack-brain-sync" + ], + "contents": { + "type": "github-skill-tree", + "skills_path": ".agents/skills", + "skill_marker": "SKILL.md" + } + }, + { + "pack_id": "bmad-method", + "display_name": "BMAD Method", + "category": "framework", + "github_url": "https://github.com/bmad-code-org/BMAD-METHOD", + "description": "Breakthrough Method for Agile AI-Driven Development: PM/architect/dev/QA agents, PRDs, architecture, stories, acceptance criteria, lifecycle workflow. Installs per-project.", + "harnesses": ["claude", "codex"], + "project_scoped": true, + "install_commands": { + "claude": "npx bmad-method install --directory . --yes", + "codex": "npx bmad-method install --directory . --yes" + }, + "uninstall_commands": { + "claude": "rm -rf ./_bmad ./_bmad-output ./.agents", + "codex": "rm -rf ./_bmad ./_bmad-output ./.agents" + }, + "install_notes": "Runs in the CURRENT directory and installs per-project. `--yes` alone does NOT skip the directory prompt — `--directory .` is required to bypass interactivity. The installer accepts module/IDE flags too (see README).", + "verified": true, + "detection_patterns": [ + "/.claude/skills/bmad-", + "/_bmad/", + "/.bmad/", + "bmad-distillator", + "bmad-prd", + "bmad-method/" + ], + "contents": { + "type": "github-multi-skill-tree", + "skill_paths": ["src/core-skills", "src/bmm-skills"], + "skill_marker": "SKILL.md" + } + }, + { + "pack_id": "awesome-claude-code", + "display_name": "Awesome Claude Code", + "category": "catalog", + "github_url": "https://github.com/hesreallyhim/awesome-claude-code", + "description": "Curated list of awesome skills, hooks, slash-commands, agent orchestrators, applications, and plugins for Claude Code. A discovery hub, not an installable pack.", + "harnesses": ["claude"], + "placeholder_reason": "Catalog repository — browse on GitHub for the actual links. Not installable as a single unit.", + "verified": true + }, + { + "pack_id": "claude-code-router", + "display_name": "Claude Code Router", + "category": "router", + "github_url": "https://github.com/musistudio/claude-code-router", + "description": "Route Claude Code through OpenRouter / DeepSeek / Ollama / Gemini and other model providers. Lets you use Claude Code's UX with alternate model backends.", + "harnesses": ["claude"], + "install_commands": { + "claude": "npm install -g @musistudio/claude-code-router" + }, + "uninstall_commands": { + "claude": "npm uninstall -g @musistudio/claude-code-router" + }, + "install_notes": "Requires Claude Code (`npm install -g @anthropic-ai/claude-code`). After install, configure ~/.claude-code-router/config.json — see README for the schema.", + "verified": true, + "post_install": { + "title": "One more step — add your LLM provider keys", + "body": "Claude Code Router is installed but won't route until you tell it which LLM providers to use. Open the config file and add API keys for OpenRouter, DeepSeek, Ollama, Gemini, or whichever providers you want to route through.", + "copy_command": "open ~/.claude-code-router/config.json 2>/dev/null || mkdir -p ~/.claude-code-router && touch ~/.claude-code-router/config.json && open ~/.claude-code-router/config.json", + "url": "https://github.com/musistudio/claude-code-router#configuration", + "required": true + }, + "contents": { + "type": "none", + "reason": "Router CLI tool, not a skill collection. Installs the `ccr` binary." + } + }, + { + "pack_id": "superclaude", + "display_name": "SuperClaude", + "category": "framework", + "github_url": "https://github.com/SuperClaude-Org/SuperClaude_Framework", + "description": "Configuration framework that enhances Claude Code with specialized commands, cognitive personas, and development methodologies. ~30 slash-command commands.", + "harnesses": ["claude"], + "install_commands": { + "claude": "pipx install superclaude && superclaude install" + }, + "uninstall_commands": { + "claude": "pipx uninstall superclaude && rm -rf ~/.claude/commands/sc" + }, + "install_notes": "Requires pipx (`brew install pipx`). v4.x CLI binary is lowercase `superclaude` (the README still says PascalCase — outdated). The CLI has NO `uninstall` subcommand; cleanup must combine `pipx uninstall` with `rm -rf ~/.claude/commands/sc`. PATH may need ~/.local/bin.", + "verified": true, + "contents": { + "type": "github-flat-md", + "md_path": "SuperClaude/Commands", + "kind": "command" + } + }, + { + "pack_id": "voltagent-subagents", + "display_name": "VoltAgent: Awesome Claude Code Subagents", + "category": "subagent-collection", + "github_url": "https://github.com/VoltAgent/awesome-claude-code-subagents", + "description": "Collection of 100+ specialized Claude Code subagents covering frontend, backend, devops, data, security, and more. Cherry-pick — installing all at once gets noisy.", + "harnesses": ["claude"], + "install_commands": { + "claude": "git clone https://github.com/VoltAgent/awesome-claude-code-subagents ~/.claude/skills/voltagent-subagents" + }, + "uninstall_commands": { + "claude": "rm -rf ~/.claude/skills/voltagent-subagents" + }, + "install_notes": "Installs the whole collection under ~/.claude/skills/voltagent-subagents/. The upstream README also offers a curl-based installer and per-agent install commands — see the repo to install only specific agents.", + "verified": true, + "contents": { + "type": "github-nested-md", + "root_path": "categories", + "kind": "agent" + } + }, + { + "pack_id": "claude-plugins-official", + "display_name": "Anthropic Official Plugin Marketplace", + "category": "marketplace", + "github_url": "https://github.com/anthropics/claude-plugins-official", + "description": "Anthropic-managed directory of high-quality Claude Code plugins (Superpowers, Compound Engineering, and more). Pre-registered with Claude Code; install individual plugins from it.", + "harnesses": ["claude"], + "install_commands": { + "claude": "claude plugin marketplace add anthropics/claude-plugins-official" + }, + "install_notes": "Pre-registered in fresh Claude Code installs. Use this entry as a pointer to the marketplace's contents (Superpowers, Compound Engineering, etc. — already in this catalog).", + "verified": true, + "contents": { + "type": "claude-marketplace", + "marketplace_repo": "anthropics/claude-plugins-official" + } + }, + { + "pack_id": "alirezarezvani-claude-skills", + "display_name": "claude-skills (alirezarezvani)", + "category": "catalog", + "github_url": "https://github.com/alirezarezvani/claude-skills", + "description": "313+ skills and plugins across Claude Code, Codex, Gemini CLI, Cursor, and 8 more coding agents — engineering, marketing, product, compliance, research, business operations.", + "harnesses": ["claude", "codex"], + "install_commands": { + "claude": "git clone https://github.com/alirezarezvani/claude-skills ~/.claude/skills/alirezarezvani-claude-skills", + "codex": "git clone https://github.com/alirezarezvani/claude-skills ~/.codex/skills/alirezarezvani-claude-skills" + }, + "uninstall_commands": { + "claude": "rm -rf ~/.claude/skills/alirezarezvani-claude-skills", + "codex": "rm -rf ~/.codex/skills/alirezarezvani-claude-skills" + }, + "install_notes": "Multi-harness catalog. The clone-and-drop-in pattern works but is heavy — the upstream README also documents an `npx agent-skills-cli add` flow + per-skill cherry-pick. Cherry-pick is recommended over the full clone.", + "verified": true, + "contents": { + "type": "github-nested-skill-tree", + "match_pattern": "*/skills/*/SKILL.md" + } + } + ] +} diff --git a/apps/desktop/src/main/packs/catalog-store.ts b/apps/desktop/src/main/packs/catalog-store.ts new file mode 100644 index 00000000..84d6b10d --- /dev/null +++ b/apps/desktop/src/main/packs/catalog-store.ts @@ -0,0 +1,607 @@ +/** + * @file catalog-store.ts + * @description PGlite persistence for the Agent Pack Catalog (FEA-1314 / + * PLN-657). Three tables: `pack_catalog` (curated packs + live GitHub + * stats), `pack_catalog_history` (append-only star/fork samples for the + * sparkline), `pack_install_runs` (audit log for install/uninstall + * subprocess executions). All operate on the shared PGlite DB handle. + * + * Catalog entries join against the FEA-1224 `agent_packs` table on + * `pack_id` to derive installed status — keep pack_id strings aligned with + * what the scanner writes (gstack, bmad-method, etc.). + * + * Schema lives in pglite.ts PGLITE_SCHEMA — no ensureCatalogSchema() here. + * + * Part of CLOSEDLOOP pack-observability (FEA-1314 / PLN-657, builds on + * FEA-1224). + */ + +import type { Results } from "@electric-sql/pglite"; + +/** Minimal subset of PgliteClient / PgliteExecutor used by catalog-store. */ +type DbClient = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowIso(): string { + return new Date().toISOString(); +} + +// --------------------------------------------------------------------------- +// Row types +// --------------------------------------------------------------------------- + +interface CatalogRow extends Record { + pack_id: string; + display_name: string; + category: string | null; + github_url: string; + marketplace_url: string | null; + description: string | null; + description_live: string | null; + harnesses: string[] | null; + install_commands: Record | null; + uninstall_commands: Record | null; + install_notes: string | null; + placeholder_reason: string | null; + verified: boolean; + readme_excerpt: string | null; + readme_fetched_at: string | null; + stars: number | null; + forks: number | null; + last_release: string | null; + last_fetched_at: string | null; + seed_version: number; + pin_order: number | null; + contents: Record | null; + contents_cache: unknown[] | null; + contents_fetched_at: string | null; + detection_patterns: string[] | null; + harness_agnostic: boolean; + project_scoped: boolean; + single_install: boolean; + post_install: Record | null; + // Derived via subquery joins: + installed_harnesses: string | null; + installed_skill_count: number | null; + uninstalled_at: string | null; +} + +interface HistoryRow extends Record { + fetched_at: string; + stars: number | null; + forks: number | null; +} + +interface InstallRunRow extends Record { + id: number; + pack_id: string; + harness: string | null; + action: string; + command: string | null; + exit_code: number | null; + started_at: string; + ended_at: string | null; + stdout_tail: string | null; + stderr_tail: string | null; +} + +interface SeedVersionRow extends Record { + pack_id: string; + seed_version: number; +} + +interface PackUsage { + tool_calls: number; + sessions: number; + first_used_at: string; + last_used_at: string; +} + +interface SeedPack { + pack_id: string; + display_name: string; + github_url: string; + category?: string | null; + marketplace_url?: string | null; + description?: string | null; + harnesses?: string[] | null; + install_commands?: Record | null; + uninstall_commands?: Record | null; + install_notes?: string | null; + placeholder_reason?: string | null; + verified?: boolean; + pin_order?: number | null; + contents?: Record | null; + detection_patterns?: string[] | null; + harness_agnostic?: boolean; + project_scoped?: boolean; + single_install?: boolean; + post_install?: Record | null; +} + +interface SeedDoc { + seed_version?: number; + packs: SeedPack[]; +} + +// --------------------------------------------------------------------------- +// Hydration — PGlite JSONB columns return parsed objects, so no JSON.parse. +// Provide safe fallback defaults for nullable JSONB fields. +// --------------------------------------------------------------------------- + +function hydrateRow(row: CatalogRow) { + return { + ...row, + harnesses: row.harnesses ?? [], + install_commands: row.install_commands ?? {}, + uninstall_commands: row.uninstall_commands ?? {}, + contents: row.contents ?? null, + contents_cache: row.contents_cache ?? null, + post_install: row.post_install ?? null, + }; +} + +// --------------------------------------------------------------------------- +// Usage attribution (best-effort) +// --------------------------------------------------------------------------- + +/** + * Compute pack usage attribution from the existing `events` table — works + * retroactively on already-imported sessions. + * + * Returns Map. + * + * TODO: Wire up to the first-party pack-store's listPackUsage once ported. + */ +async function loadUsageMap( + _db: DbClient, +): Promise> { + // pack-store with listPackUsage is not yet ported to the first-party app. + // Return an empty map until the dependency is available. + return new Map(); +} + +// --------------------------------------------------------------------------- +// Seed upsert +// --------------------------------------------------------------------------- + +/** + * Apply the seed JSON to `pack_catalog`. Each row's `seed_version` is + * compared against the seed's top-level `seed_version`; rows whose stored + * seed_version is lower (or absent) are upserted. Higher stored seed_versions + * are left alone (assume a newer seed was previously applied — don't roll + * back). + * + * Live fields (stars, forks, description_live, last_fetched_at) are NEVER + * touched by this function — they're owned by the fetcher. + */ +export async function upsertCatalogSeed( + db: DbClient, + seedDoc: SeedDoc | null | undefined, +): Promise<{ inserted: number; updated: number; skipped: number }> { + if (!seedDoc || !Array.isArray(seedDoc.packs)) { + return { inserted: 0, updated: 0, skipped: 0 }; + } + const seedVersion = Number.isInteger(seedDoc.seed_version) + ? seedDoc.seed_version! + : 1; + const stats = { inserted: 0, updated: 0, skipped: 0 }; + + for (const pack of seedDoc.packs) { + if (!pack || !pack.pack_id || !pack.display_name || !pack.github_url) { + continue; + } + + const existingResult = await db.query( + "SELECT pack_id, seed_version FROM pack_catalog WHERE pack_id = $1", + [pack.pack_id], + ); + const existing = existingResult.rows[0] ?? null; + + if (existing && existing.seed_version >= seedVersion) { + stats.skipped += 1; + continue; + } + + await db.query( + `INSERT INTO pack_catalog + (pack_id, display_name, category, github_url, marketplace_url, + description, harnesses, install_commands, uninstall_commands, + install_notes, placeholder_reason, verified, pin_order, contents, + detection_patterns, harness_agnostic, project_scoped, + single_install, post_install, seed_version) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ON CONFLICT(pack_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + category = EXCLUDED.category, + github_url = EXCLUDED.github_url, + marketplace_url = EXCLUDED.marketplace_url, + description = EXCLUDED.description, + harnesses = EXCLUDED.harnesses, + install_commands = EXCLUDED.install_commands, + uninstall_commands = EXCLUDED.uninstall_commands, + install_notes = EXCLUDED.install_notes, + placeholder_reason = EXCLUDED.placeholder_reason, + verified = EXCLUDED.verified, + pin_order = EXCLUDED.pin_order, + contents = EXCLUDED.contents, + detection_patterns = EXCLUDED.detection_patterns, + harness_agnostic = EXCLUDED.harness_agnostic, + project_scoped = EXCLUDED.project_scoped, + single_install = EXCLUDED.single_install, + post_install = EXCLUDED.post_install, + seed_version = EXCLUDED.seed_version`, + [ + pack.pack_id, + pack.display_name, + pack.category ?? null, + pack.github_url, + pack.marketplace_url ?? null, + pack.description ?? null, + pack.harnesses ? JSON.stringify(pack.harnesses) : null, + pack.install_commands ? JSON.stringify(pack.install_commands) : null, + pack.uninstall_commands + ? JSON.stringify(pack.uninstall_commands) + : null, + pack.install_notes ?? null, + pack.placeholder_reason ?? null, + pack.verified ? true : false, + typeof pack.pin_order === "number" ? pack.pin_order : null, + pack.contents ? JSON.stringify(pack.contents) : null, + Array.isArray(pack.detection_patterns) + ? JSON.stringify(pack.detection_patterns) + : null, + pack.harness_agnostic ? true : false, + pack.project_scoped ? true : false, + pack.single_install ? true : false, + pack.post_install ? JSON.stringify(pack.post_install) : null, + seedVersion, + ], + ); + + if (existing) stats.updated += 1; + else stats.inserted += 1; + } + + return stats; +} + +// --------------------------------------------------------------------------- +// Catalog listing +// --------------------------------------------------------------------------- + +/** + * List all catalog entries. Sort order: + * 1. Pinned entries (pin_order ASC) — ClosedLoop always lands first. + * 2. Everything else by star count DESC. + * 3. Tiebreak: display name ASC. + * + * Installed status is decorated via subquery joins against `agent_packs` and + * `skills`. + */ +export async function listCatalog(db: DbClient) { + const result = await db.query( + `SELECT + c.*, + (SELECT string_agg(DISTINCT ap.harness, ',') + FROM agent_packs ap + WHERE ap.pack_id = c.pack_id + AND ap.uninstalled_at IS NULL) AS installed_harnesses, + (SELECT COUNT(*) FROM skills s + WHERE s.pack_id = c.pack_id + AND s.uninstalled_at IS NULL) AS installed_skill_count, + (SELECT MAX(ap.uninstalled_at) + FROM agent_packs ap + WHERE ap.pack_id = c.pack_id + AND ap.uninstalled_at IS NOT NULL) AS uninstalled_at + FROM pack_catalog c + ORDER BY + CASE WHEN c.pin_order IS NULL THEN 1 ELSE 0 END ASC, + c.pin_order ASC, + COALESCE(c.stars, 0) DESC, + c.display_name ASC`, + ); + const rows = result.rows; + + const usage = await loadUsageMap(db); + + return rows.map((r) => ({ + ...hydrateRow(r), + installed_harnesses: r.installed_harnesses + ? r.installed_harnesses.split(",") + : [], + usage: usage.get(r.pack_id) || null, + })); +} + +// --------------------------------------------------------------------------- +// Single-entry detail +// --------------------------------------------------------------------------- + +/** Get one catalog entry by pack_id, with installed status + recent history. */ +export async function getCatalog( + db: DbClient, + packId: string, + { historyDays = 30 }: { historyDays?: number } = {}, +) { + const result = await db.query( + `SELECT + c.*, + (SELECT string_agg(DISTINCT ap.harness, ',') + FROM agent_packs ap + WHERE ap.pack_id = c.pack_id + AND ap.uninstalled_at IS NULL) AS installed_harnesses, + (SELECT COUNT(*) FROM skills s + WHERE s.pack_id = c.pack_id + AND s.uninstalled_at IS NULL) AS installed_skill_count, + (SELECT MAX(ap.uninstalled_at) + FROM agent_packs ap + WHERE ap.pack_id = c.pack_id + AND ap.uninstalled_at IS NOT NULL) AS uninstalled_at + FROM pack_catalog c + WHERE c.pack_id = $1`, + [packId], + ); + const row = result.rows[0] ?? null; + if (!row) return null; + + const usage = await loadUsageMap(db); + + return { + ...hydrateRow(row), + installed_harnesses: row.installed_harnesses + ? row.installed_harnesses.split(",") + : [], + usage: usage.get(packId) || null, + history: await listHistory(db, packId, historyDays), + }; +} + +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + +export async function listHistory( + db: DbClient, + packId: string, + days = 30, +): Promise { + const since = new Date( + Date.now() - days * 24 * 60 * 60 * 1000, + ).toISOString(); + const result = await db.query( + `SELECT fetched_at, stars, forks + FROM pack_catalog_history + WHERE pack_id = $1 AND fetched_at >= $2 + ORDER BY fetched_at ASC`, + [packId, since], + ); + return result.rows; +} + +// --------------------------------------------------------------------------- +// Fetch results (GitHub stats) +// --------------------------------------------------------------------------- + +/** + * Update the live fields after a successful GitHub fetch + append a history + * sample. Called by the fetcher. + */ +export async function applyFetchResult( + db: DbClient, + { + pack_id, + stars, + forks, + description, + last_release, + }: { + pack_id: string; + stars?: number | null; + forks?: number | null; + description?: string | null; + last_release?: string | null; + }, +): Promise { + const ts = nowIso(); + await db.query( + `UPDATE pack_catalog + SET stars = $1, + forks = $2, + description_live = COALESCE($3, description_live), + last_release = COALESCE($4, last_release), + last_fetched_at = $5 + WHERE pack_id = $6`, + [ + stars ?? null, + forks ?? null, + description || null, + last_release || null, + ts, + pack_id, + ], + ); + if (stars != null || forks != null) { + await db.query( + `INSERT INTO pack_catalog_history + (pack_id, fetched_at, stars, forks) + VALUES ($1, $2, $3, $4) + ON CONFLICT (pack_id, fetched_at) DO UPDATE SET + stars = EXCLUDED.stars, + forks = EXCLUDED.forks`, + [pack_id, ts, stars ?? null, forks ?? null], + ); + } +} + +// --------------------------------------------------------------------------- +// README fetch +// --------------------------------------------------------------------------- + +/** + * Update the README excerpt for a pack. Called by the catalog-route's + * on-demand README fetcher (lazy — README is only pulled when a user + * opens the detail modal). + */ +export async function applyReadmeFetch( + db: DbClient, + { + pack_id, + readme_excerpt, + }: { pack_id: string; readme_excerpt: string | null }, +): Promise { + await db.query( + `UPDATE pack_catalog + SET readme_excerpt = $1, + readme_fetched_at = $2 + WHERE pack_id = $3`, + [readme_excerpt || null, nowIso(), pack_id], + ); +} + +// --------------------------------------------------------------------------- +// Contents fetch +// --------------------------------------------------------------------------- + +/** + * Cache the per-pack contents listing fetched by catalog-contents. + * `items` is an array of `{ name, kind, description?, path? }`. + */ +export async function applyContentsFetch( + db: DbClient, + { + pack_id, + items, + }: { pack_id: string; items: unknown[] | null | undefined }, +): Promise { + await db.query( + `UPDATE pack_catalog + SET contents_cache = $1, + contents_fetched_at = $2 + WHERE pack_id = $3`, + [items == null ? null : JSON.stringify(items), nowIso(), pack_id], + ); +} + +// --------------------------------------------------------------------------- +// Install runs (audit log) +// --------------------------------------------------------------------------- + +interface InsertedIdRow extends Record { + id: number; +} + +export async function recordInstallRunStart( + db: DbClient, + { + pack_id, + harness, + action, + command, + }: { pack_id: string; harness: string; action: string; command: string }, +): Promise { + const ts = nowIso(); + const result = await db.query( + `INSERT INTO pack_install_runs (pack_id, harness, action, command, started_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [pack_id, harness, action, command, ts], + ); + return result.rows[0]!.id; +} + +export async function recordInstallRunEnd( + db: DbClient, + id: number, + { + exit_code, + stdout_tail, + stderr_tail, + }: { + exit_code?: number | null; + stdout_tail?: string | null; + stderr_tail?: string | null; + }, +): Promise { + await db.query( + `UPDATE pack_install_runs + SET exit_code = $1, + ended_at = $2, + stdout_tail = $3, + stderr_tail = $4 + WHERE id = $5`, + [ + exit_code ?? null, + nowIso(), + stdout_tail || null, + stderr_tail || null, + id, + ], + ); +} + +interface InFlightRow extends Record { + id: number; + harness: string | null; + command: string | null; + started_at: string; +} + +export async function inFlightInstallRun( + db: DbClient, + packId: string, +): Promise { + const result = await db.query( + `SELECT id, harness, command, started_at FROM pack_install_runs + WHERE pack_id = $1 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1`, + [packId], + ); + return result.rows[0] ?? null; +} + +export async function listInstallRuns( + db: DbClient, + { + pack_id = null, + limit = 50, + offset = 0, + }: { pack_id?: string | null; limit?: number; offset?: number } = {}, +): Promise { + if (pack_id) { + const result = await db.query( + `SELECT * FROM pack_install_runs + WHERE pack_id = $1 + ORDER BY started_at DESC + LIMIT $2 OFFSET $3`, + [pack_id, limit, offset], + ); + return result.rows; + } + const result = await db.query( + `SELECT * FROM pack_install_runs + ORDER BY started_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + return result.rows; +} + +export async function deleteInstallRun( + db: DbClient, + id: number, +): Promise { + const result = await db.query( + `DELETE FROM pack_install_runs WHERE id = $1 AND ended_at IS NOT NULL`, + [id], + ); + return (result.affectedRows ?? 0) > 0; +} diff --git a/apps/desktop/src/main/packs/install-orchestrator.ts b/apps/desktop/src/main/packs/install-orchestrator.ts new file mode 100644 index 00000000..22eea140 --- /dev/null +++ b/apps/desktop/src/main/packs/install-orchestrator.ts @@ -0,0 +1,645 @@ +/** + * @file install-orchestrator.ts + * @description Spawns install / uninstall subprocesses for catalog packs and + * streams output to the renderer via Electron IPC. Every run is recorded in + * `pack_install_runs` for audit. After a successful install/uninstall the + * caller's onComplete hook fires so the pack scanner can rescan. + * + * Ported from the old sidecar's install-orchestrator.js + catalog-action-handler.js + * into a single first-party Electron ESM module. + * + * Safeguards: + * - Hard timeout (default 10 min) — subprocess killed if it overruns + * - Concurrent-install guard: refuses if a run for the same pack is still + * in-flight (ended_at IS NULL) + * - ANSI escape codes stripped from stored tails (full output stays in the + * live IPC stream) + * - Security-hardened minimal env for child processes (no leaked tokens) + */ + +import { spawn, execFileSync } from "node:child_process"; +import { statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { BrowserWindow } from "electron"; +import type { Results } from "@electric-sql/pglite"; +import { gatewayLog } from "../gateway-logger.js"; +import { + getCatalog, + recordInstallRunStart, + recordInstallRunEnd, + inFlightInstallRun, +} from "./catalog-store.js"; + +type DbClient = { + query = Record>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const TAIL_BYTES = 4096; + +const TRUSTED_ACTION_HEADER = "x-agent-dashboard-trusted-action"; +const TRUSTED_ACTION_VALUE = "catalog-mutate"; +const ALLOWED_ORIGIN_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", +]); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InstallOutputChunk { + runId: number; + type: "start" | "stdout" | "stderr" | "error" | "post_install" | "copy_command" | "complete"; + data: unknown; +} + +export interface StreamRunOptions { + pack_id: string; + harness: string; + action: "install" | "uninstall"; + cwd?: string; + getWindow: () => BrowserWindow | null; + onComplete?: (result: { exit_code: number; killed: boolean }) => void; + timeoutMs?: number; +} + +export interface StreamRunResult { + started: boolean; + runId?: number; + error?: { code: string; message: string }; +} + +interface TrustedActionResult { + ok: boolean; + statusCode?: number; + error?: { code: string; message: string }; +} + +interface CatalogEntry { + pack_id: string; + single_install?: number; + project_scoped?: boolean | number; + harnesses?: string[]; + install_commands?: Record; + uninstall_commands?: Record; + post_install?: unknown; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// ANSI stripping + tail helpers +// --------------------------------------------------------------------------- + +const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g; + +function stripAnsi(s: string): string { + return typeof s === "string" ? s.replace(ANSI_RE, "") : s; +} + +function tailBytes(buffer: string): string | null { + if (!buffer) return null; + const stripped = stripAnsi(buffer); + if (stripped.length <= TAIL_BYTES) return stripped; + return "\u2026" + stripped.slice(stripped.length - TAIL_BYTES); +} + +// --------------------------------------------------------------------------- +// IPC send helper (replaces SSE) +// --------------------------------------------------------------------------- + +function sendIpc( + getWindow: () => BrowserWindow | null, + runId: number, + type: InstallOutputChunk["type"], + data: unknown, +): void { + const win = getWindow(); + if (!win || win.isDestroyed()) return; + const chunk: InstallOutputChunk = { runId, type, data }; + win.webContents.send("desktop:pack:install-output", chunk); +} + +// --------------------------------------------------------------------------- +// Environment & CWD helpers +// --------------------------------------------------------------------------- + +/** + * Minimal env passed to child install processes. + * + * Only an allowlist of variables needed for sane CLI execution (PATH for + * binary lookup, HOME / USER for ~/ expansion, LANG / TERM for proper + * rendering, SHELL for `sh -c`) is passed through. A malicious or + * compromised catalog entry cannot exfiltrate ClosedLoop tokens, PostHog + * keys, API keys, or shell credentials. + */ +export function buildAllowedChildEnv( + parentEnv: Record = process.env, + cwd: string | null = null, +): Record { + const allowed = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMPDIR", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "PYTHONUNBUFFERED", + ]; + const out: Record = {}; + for (const key of allowed) { + const val = parentEnv[key]; + if (typeof val === "string" && val.length > 0) { + out[key] = val; + } + } + if (!out.HOME) out.HOME = homedir(); + if (cwd) { + out.INIT_CWD = cwd; + out.PWD = cwd; + } + return out; +} + +/** + * Heuristic for catalog commands that operate on the current directory and + * must NOT be run without an explicit, validated project cwd. Only matches + * unambiguous "writes to cwd" signals: + * --directory . (npx-style) + * --directory=. (gnu-arg-style) + * -C . (make / git -C style) + */ +const PROJECT_RELATIVE_HINTS = ["--directory .", "--directory=.", " -C ."]; + +export function looksProjectRelative(command: string): boolean { + if (typeof command !== "string") return false; + return PROJECT_RELATIVE_HINTS.some((hint) => command.includes(hint)); +} + +/** + * Validate and resolve a requested CWD for subprocess spawning. + * Throws with `.code = "EBADCWD"` on invalid input. + */ +export function resolveSpawnCwd(requestedCwd: string | undefined | null): string | null { + if (typeof requestedCwd !== "string" || requestedCwd.trim().length === 0) { + return null; + } + + const trimmed = requestedCwd.trim(); + if (!path.isAbsolute(trimmed)) { + const err = new Error("cwd must be an absolute path") as Error & { code: string }; + err.code = "EBADCWD"; + throw err; + } + + const abs = path.resolve(trimmed); + let stat: ReturnType; + try { + stat = statSync(abs); + } catch { + const err = new Error(`cwd does not exist: ${abs}`) as Error & { code: string }; + err.code = "EBADCWD"; + throw err; + } + if (!stat.isDirectory()) { + const err = new Error(`not a directory: ${abs}`) as Error & { code: string }; + err.code = "EBADCWD"; + throw err; + } + if (abs === "/" || abs === path.parse(abs).root) { + const err = new Error("refusing to spawn at filesystem root") as Error & { code: string }; + err.code = "EBADCWD"; + throw err; + } + return abs; +} + +// --------------------------------------------------------------------------- +// Harness detection +// --------------------------------------------------------------------------- + +const HARNESS_CLI_BINARIES: Record = { + claude: "claude", + codex: "codex", +}; + +/** + * Probe whether a harness CLI is installed on PATH. Used by `single_install` + * packs so we install only for the harnesses the user actually has. + * Best-effort and short-timeout — never blocks long. + */ +export function isHarnessInstalled(harness: string): boolean { + const bin = HARNESS_CLI_BINARIES[harness]; + if (!bin) return false; + try { + const out = execFileSync("/usr/bin/which", [bin], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 1000, + }); + return Boolean(out.toString().trim()); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Command selection for single_install packs +// --------------------------------------------------------------------------- + +/** + * Join independent cleanup commands so each runs regardless of whether + * prior ones fail, but the aggregate exit code reflects any failure. + */ +export function joinIndependentCleanupCommands(commands: string[]): string { + const failureVar = "__closedloop_uninstall_failed"; + return [ + `${failureVar}=0`, + ...commands.map((command) => `if ! ( ${command} ); then ${failureVar}=1; fi`), + `exit $${failureVar}`, + ].join("; "); +} + +/** + * For `single_install` packs (gstack), pick the command to run for install + * or uninstall. + * + * INSTALL — pick the SUPERSET command. By convention the codex install + * command is a superset of the claude install command. Running it once + * installs for all detected CLIs. + * + * UNINSTALL — run all uninstall commands independently but aggregate + * failures. Runs for ALL listed harnesses (not just CLIs on PATH) because + * on-disk artifacts may outlive the CLI install. + * + * Returns { command, registerHarnesses }. + */ +export function pickSingleInstallCommand( + entry: CatalogEntry, + action: "install" | "uninstall", +): { command: string | null; registerHarnesses: string[] } { + const cmdMap = + action === "uninstall" ? entry.uninstall_commands : entry.install_commands; + const harnesses = Array.isArray(entry.harnesses) ? entry.harnesses : []; + + if (action === "uninstall") { + // Run ALL listed harnesses' uninstall commands independently. + const cmds = harnesses + .map((h) => cmdMap && cmdMap[h]) + .filter((c): c is string => Boolean(c)); + if (cmds.length === 0) { + return { command: null, registerHarnesses: [] }; + } + return { + command: joinIndependentCleanupCommands(cmds), + registerHarnesses: harnesses, + }; + } + + // Install path — only consider harnesses whose CLI is actually present. + const installed = harnesses.filter(isHarnessInstalled); + if (installed.length === 0) { + return { command: null, registerHarnesses: [] }; + } + // Prefer codex command when codex is present (superset convention). + const codexFirst = ["codex", "claude"]; + for (const h of codexFirst) { + if (installed.includes(h) && cmdMap && cmdMap[h]) { + return { command: cmdMap[h], registerHarnesses: installed }; + } + } + // Last resort: any command for any installed harness. + for (const h of installed) { + if (cmdMap && cmdMap[h]) { + return { command: cmdMap[h], registerHarnesses: installed }; + } + } + return { command: null, registerHarnesses: [] }; +} + +// --------------------------------------------------------------------------- +// Origin / trusted-action validation (ported from catalog-action-handler.js) +// --------------------------------------------------------------------------- + +function firstHeaderValue(raw: string | string[] | undefined | null): string | null { + if (Array.isArray(raw)) { + return raw.length > 0 ? raw[0] : null; + } + return typeof raw === "string" ? raw : null; +} + +export function isAllowedDashboardOrigin( + origin: string | null, + expectedPort?: string, +): boolean { + if (!origin || origin === "null") return false; + try { + const url = new URL(origin); + const port = expectedPort ?? String(process.env.DASHBOARD_PORT || "4820"); + return ( + url.protocol === "http:" && + ALLOWED_ORIGIN_HOSTS.has(url.hostname) && + url.port === port + ); + } catch { + return false; + } +} + +/** + * Validate that a request comes from a trusted dashboard origin and carries + * the trusted-action header. Used as a guard before install/uninstall + * mutations. + */ +export function validateTrustedAction( + origin: string | string[] | undefined | null, + trustedActionHeader: string | string[] | undefined | null, +): TrustedActionResult { + const originStr = firstHeaderValue(origin); + if (!originStr || originStr === "null") { + return { + ok: false, + statusCode: 403, + error: { + code: "EORIGINREQUIRED", + message: "Origin header required for catalog install/uninstall", + }, + }; + } + if (!isAllowedDashboardOrigin(originStr)) { + return { + ok: false, + statusCode: 403, + error: { + code: "EBADORIGIN", + message: "cross-origin requests are not allowed", + }, + }; + } + const trustedAction = firstHeaderValue(trustedActionHeader); + if (trustedAction !== TRUSTED_ACTION_VALUE) { + return { + ok: false, + statusCode: 403, + error: { + code: "EUNTRUSTEDACTION", + message: "missing trusted action header", + }, + }; + } + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Core: streamRun +// --------------------------------------------------------------------------- + +/** + * Run an install (or uninstall) command for a catalog pack and stream output + * to the renderer via Electron IPC. + * + * @param db — DB handle (catalog-store compatible) + * @param opts — see StreamRunOptions + * @returns A result indicating whether the run was started or rejected + */ +export async function streamRun(db: DbClient, opts: StreamRunOptions): Promise { + const { pack_id, harness, action, getWindow, onComplete } = opts; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const requestedCwd = typeof opts.cwd === "string" ? opts.cwd.trim() : ""; + + // Placeholder runId for error events sent before a DB row exists + const errorRunId = -1; + + const entry = await getCatalog(db, pack_id) as CatalogEntry | null; + if (!entry) { + sendIpc(getWindow, errorRunId, "error", { + message: `pack_id not in catalog: ${pack_id}`, + }); + sendIpc(getWindow, errorRunId, "complete", { exit_code: -1, reason: "not_found" }); + return { started: false, error: { code: "ENOTFOUND", message: `pack_id not in catalog: ${pack_id}` } }; + } + + // For `single_install` packs, the catalog UI sends harness="auto" and we + // pick the command that covers all installed CLIs in one run. + let command: string | null | undefined; + // resolvedHarnesses tracks which CLIs this run covers (used by callers for + // pack scanner registration after successful install). + let _resolvedHarnesses: string[] = [harness]; + + if (entry.single_install === 1 && harness === "auto") { + const picked = pickSingleInstallCommand(entry, action); + command = picked.command; + _resolvedHarnesses = picked.registerHarnesses; + if (!command) { + const noCommandMessage = + action === "uninstall" + ? `pack '${pack_id}' is single_install but no uninstall commands are configured for any listed harness.` + : `pack '${pack_id}' is single_install but no supported CLI is on PATH. ` + + `Install Claude Code or Codex first, then try again.`; + sendIpc(getWindow, errorRunId, "error", { message: noCommandMessage }); + sendIpc(getWindow, errorRunId, "complete", { + exit_code: -1, + reason: action === "uninstall" ? "no_command" : "no_cli_detected", + }); + return { + started: false, + error: { + code: action === "uninstall" ? "ENOCOMMAND" : "ENOCLI", + message: noCommandMessage, + }, + }; + } + } else { + const commandMap = + action === "uninstall" ? entry.uninstall_commands : entry.install_commands; + command = commandMap && commandMap[harness]; + if (!command) { + const msg = `no ${action} command for harness '${harness}' on pack '${pack_id}'`; + sendIpc(getWindow, errorRunId, "error", { message: msg }); + sendIpc(getWindow, errorRunId, "complete", { exit_code: -1, reason: "no_command" }); + return { started: false, error: { code: "ENOCOMMAND", message: msg } }; + } + } + + // Validate CWD + let resolvedCwd: string | null = null; + try { + resolvedCwd = resolveSpawnCwd(requestedCwd); + } catch (error: unknown) { + const errObj = error as Error & { code?: string }; + const code = errObj.code ?? "EBADCWD"; + const message = errObj.message ?? "invalid cwd"; + sendIpc(getWindow, errorRunId, "error", { code, message }); + sendIpc(getWindow, errorRunId, "complete", { exit_code: -1, reason: "invalid_cwd" }); + return { started: false, error: { code, message } }; + } + + // Concurrency guard + const inFlight = await inFlightInstallRun(db, pack_id); + if (inFlight) { + const msg = `another run for ${pack_id} is already in-flight (started ${inFlight.started_at})`; + sendIpc(getWindow, errorRunId, "error", { + message: msg, + in_flight_run_id: inFlight.id, + }); + sendIpc(getWindow, errorRunId, "complete", { exit_code: -1, reason: "in_flight" }); + return { started: false, error: { code: "EINFLIGHT", message: msg } }; + } + + // Project-scoped guard + const requiresProjectCwd = + entry.project_scoped === true || + entry.project_scoped === 1 || + looksProjectRelative(command); + if (requiresProjectCwd && !resolvedCwd) { + sendIpc(getWindow, errorRunId, "copy_command", { + pack_id, + command, + reason: + entry.project_scoped === true || entry.project_scoped === 1 + ? "project_scoped" + : "looks_project_relative", + }); + const msg = + `pack '${pack_id}' is project-scoped (command operates on cwd). ` + + `Provide an explicit \`cwd\` for the install — otherwise it would ` + + `run in the app's launch directory, not your project.`; + sendIpc(getWindow, errorRunId, "error", { message: msg }); + sendIpc(getWindow, errorRunId, "complete", { exit_code: -1, reason: "cwd_required" }); + return { started: false, error: { code: "ECWDREQUIRED", message: msg } }; + } + + // Record the run and start streaming + const runId = await recordInstallRunStart(db, { pack_id, harness, action, command }); + sendIpc(getWindow, runId, "start", { run_id: runId, command, cwd: resolvedCwd }); + + let stdoutBuf = ""; + let stderrBuf = ""; + let killed = false; + + const spawnOpts: { + stdio: ["ignore", "pipe", "pipe"]; + env: Record; + cwd?: string; + } = { + stdio: ["ignore", "pipe", "pipe"], + env: buildAllowedChildEnv(process.env, resolvedCwd), + }; + if (resolvedCwd) spawnOpts.cwd = resolvedCwd; + + const child = spawn("sh", ["-c", command], spawnOpts); + + const timer = setTimeout(() => { + killed = true; + sendIpc( + getWindow, + runId, + "stderr", + `[install-orchestrator] timeout after ${timeoutMs}ms — killing\n`, + ); + try { + child.kill("SIGTERM"); + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already dead */ + } + }, 2000); + } catch { + /* already dead */ + } + }, timeoutMs); + + child.stdout.on("data", (chunk: Buffer) => { + const s = chunk.toString("utf8"); + stdoutBuf += s; + sendIpc(getWindow, runId, "stdout", s); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const s = chunk.toString("utf8"); + stderrBuf += s; + sendIpc(getWindow, runId, "stderr", s); + }); + + child.on("error", (err: Error) => { + sendIpc( + getWindow, + runId, + "stderr", + `[install-orchestrator] spawn error: ${err.message}\n`, + ); + }); + + child.on("close", (code: number | null, signal: string | null) => { + clearTimeout(timer); + const exitCode = code != null ? code : signal ? -1 : -1; + void recordInstallRunEnd(db, runId, { + exit_code: killed ? -1 : exitCode, + stdout_tail: tailBytes(stdoutBuf), + stderr_tail: tailBytes(stderrBuf), + }); + + // On successful install: surface the pack's post_install block before the + // complete event so the client can render a "next steps" screen. + if (!killed && exitCode === 0 && action === "install" && entry.post_install) { + sendIpc(getWindow, runId, "post_install", entry.post_install); + } + + sendIpc(getWindow, runId, "complete", { + exit_code: killed ? -1 : exitCode, + reason: killed ? "timeout" : signal ? `signal:${signal}` : "exit", + run_id: runId, + }); + + if (typeof onComplete === "function") { + try { + onComplete({ exit_code: exitCode, killed }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + gatewayLog.warn("[install-orchestrator] onComplete callback failed:", msg); + } + } + }); + + return { started: true, runId }; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export { + TRUSTED_ACTION_HEADER, + TRUSTED_ACTION_VALUE, +}; + +// Test-only internals (mirrors the old _internals export for unit tests) +export const _internals = { + buildAllowedChildEnv, + looksProjectRelative, + resolveSpawnCwd, + stripAnsi, + tailBytes, + isHarnessInstalled, + joinIndependentCleanupCommands, + pickSingleInstallCommand, + sendIpc, +}; diff --git a/apps/desktop/src/main/packs/pack-scanner.ts b/apps/desktop/src/main/packs/pack-scanner.ts new file mode 100644 index 00000000..9997af84 --- /dev/null +++ b/apps/desktop/src/main/packs/pack-scanner.ts @@ -0,0 +1,1326 @@ +/** + * @file pack-scanner.ts — filesystem-driven discovery of agent skill packs + * (GStack + BMad Method + catalog detection adapters) for the first-party + * Electron Agent Dashboard. + * + * Ported from the legacy sidecar CJS modules (pack-scanner.js + + * catalog-detector.js) into a single TypeScript ESM module. The pack-store + * upsert functions are async (PGlite), so every scanner function is async too. + * The `fs` calls stay synchronous — they are filesystem probing, not + * performance-critical. + * + * Runs after the PGlite schema has been applied (schema lives in pglite.ts). + * Walks well-known skills roots (`~/.claude/skills`, `~/.codex/skills`) and + * active project roots (distinct `sessions.cwd` from recent rows) and upserts + * into `agent_packs`, `skills`, and `project_pack_associations`. Idempotent — + * re-running bumps `last_seen_at` but never produces duplicate rows. + */ + +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + lstatSync, + readdirSync, + readFileSync, + realpathSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { Results } from "@electric-sql/pglite"; + +import { + upsertPack, + upsertSkill, + upsertProjectAssociation, +} from "./pack-store.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Minimal async DB handle (subset of PGlite API used by the scanner). */ +export interface PackScannerDb { + query = Record>( + query: string, + params?: unknown[], + ): Promise>; + exec(query: string): Promise; +} + +interface ScanGStackResult { + installs: number; + skills: number; +} + +interface ScanBmadResult { + installs: number; + skills: number; + projects: number; +} + +interface ScanMarketplacesResult { + installs: number; + skills: number; + marketplaces: number; + plugins?: number; +} + +interface RunPackScannerOverrides { + scanGStack?: (db: PackScannerDb) => Promise; + scanBmad?: (db: PackScannerDb) => Promise; + scanClaudeMarketplaces?: ( + db: PackScannerDb, + ) => Promise; + scanProjectGStackAssociations?: (db: PackScannerDb) => Promise; + runCatalogDetectorAdapters?: ( + db: PackScannerDb, + ) => Promise>; +} + +export interface PackScannerSummary { + gstack: ScanGStackResult; + bmad: ScanBmadResult; + marketplaces: ScanMarketplacesResult; + catalogDetectors: Record; + gstackProjects: number; + prunedBefore: string; + scopes: Record; + pruned: boolean; + pruneSkipped: boolean; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PROJECT_LOOKBACK_DAYS = 90; +const GIT_REMOTE_TIMEOUT_MS = 1500; + +// Known Claude Code plugin marketplaces -> upstream repo URL. Marketplaces +// outside this table are still detected and registered as packs; they just +// get no source_url chip in the UI. +const KNOWN_MARKETPLACE_SOURCES: Record = { + "closedloop-ai": "https://github.com/closedloop-ai/claude-plugins", + "claude-plugins-official": + "https://github.com/anthropics/claude-plugins-official", +}; + +// Marketplaces whose plugins are conceptually ONE bundle (one logical pack +// shipped together) rather than independently-installable units. closedloop-ai +// is the canonical example: code/code-review/judges/platform/self-learning +// are 5 plugins but install as a unit and the user thinks of them as "the +// closedloop pack." For these, the agent_packs row is keyed on the +// marketplace name and skills aggregate across plugins. +// +// For all OTHER marketplaces (anthropics/claude-plugins-official, etc.) each +// plugin becomes its OWN pack — pack_id = plugin name. That matches the +// catalog's pack_id values (e.g. superpowers, compound-engineering) so the +// "installed" join in listCatalog works correctly. +const MARKETPLACE_BUNDLE_AS_PACK = new Set(["closedloop-ai"]); + +// --------------------------------------------------------------------------- +// Filesystem helpers (sync — fine for probing) +// --------------------------------------------------------------------------- + +function resolveClaudeHome(): string { + return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude"); +} + +function resolveCodexHome(): string { + return process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); +} + +function safeStat(p: string) { + try { + return lstatSync(p); + } catch { + return null; + } +} + +function safeReadDir(p: string) { + try { + return readdirSync(p, { withFileTypes: true }); + } catch { + return []; + } +} + +function safeReadFile(p: string): string | null { + try { + return readFileSync(p, "utf8"); + } catch { + return null; + } +} + +function safeRealpath(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +function isSymlink(p: string): boolean { + const st = safeStat(p); + return !!st && st.isSymbolicLink(); +} + +// --------------------------------------------------------------------------- +// Deterministic IDs and frontmatter +// --------------------------------------------------------------------------- + +export function deterministicSkillId( + harness: string, + installPath: string, + name: string, +): string { + return createHash("sha256") + .update(`${harness}|${installPath}|${name}`) + .digest("hex") + .slice(0, 32); +} + +/** + * Parse YAML frontmatter from a SKILL.md file. Lenient: missing fields are + * returned as null rather than throwing. Returns null when no frontmatter + * block is present. + */ +export function parseSkillFrontmatter( + content: string, +): Record | null { + if (typeof content !== "string") return null; + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + const fields: Record = {}; + for (const rawLine of match[1].split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf(":"); + if (sep < 0) continue; + const key = line.slice(0, sep).trim().toLowerCase(); + let value = line.slice(sep + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + fields[key] = value; + } + return fields; +} + +// --------------------------------------------------------------------------- +// Recursive skill-file walker +// --------------------------------------------------------------------------- + +/** + * Walk a directory recursively (bounded depth) and yield every SKILL.md path. + * Symlinks inside a pack are followed once; depth is capped to avoid runaway + * traversal if a user has a weird layout. + */ +function findSkillFiles(root: string, maxDepth = 6): string[] { + const results: string[] = []; + const stack: Array<{ dir: string; depth: number }> = [ + { dir: root, depth: 0 }, + ]; + while (stack.length) { + const { dir, depth } = stack.pop()!; + if (depth > maxDepth) continue; + for (const entry of safeReadDir(dir)) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + stack.push({ dir: full, depth: depth + 1 }); + } else if (entry.isFile() && entry.name === "SKILL.md") { + results.push(full); + } + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Git remote helper +// --------------------------------------------------------------------------- + +function deriveGitRemoteUrl(dir: string): string | null { + try { + const out = execFileSync( + "git", + ["-C", dir, "remote", "get-url", "origin"], + { + timeout: GIT_REMOTE_TIMEOUT_MS, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + return out.toString("utf8").trim() || null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Recent project roots (shared by BMad + project-association scanners) +// --------------------------------------------------------------------------- + +/** + * Distinct recent-session cwds, used by every per-project scanner that needs + * to look at which projects the user worked in recently. Lookback window is + * PROJECT_LOOKBACK_DAYS. + * + * Returns string[] of absolute paths (already de-duped by SELECT DISTINCT). + * Returns [] (not throws) on any failure. + */ +async function getRecentProjectRoots(db: PackScannerDb): Promise { + try { + const since = new Date( + Date.now() - PROJECT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + const result = await db.query<{ cwd: string }>( + `SELECT DISTINCT cwd FROM sessions + WHERE cwd IS NOT NULL AND cwd != '' + AND (updated_at >= $1 OR started_at >= $2)`, + [since, since], + ); + return result.rows.map((r) => r.cwd).filter(Boolean); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// GStack version reader +// --------------------------------------------------------------------------- + +/** + * Read the gstack pack version from the install's top-level VERSION file + * (plain-text, e.g. "1.40.0.0"). Returns null if absent or unreadable. + */ +function readGStackVersion(installPath: string): string | null { + const versionFile = path.join(installPath, "VERSION"); + const content = safeReadFile(versionFile); + if (!content) return null; + const v = content.trim().split(/\s+/)[0]; + return v && /^[0-9][0-9A-Za-z.\-+]*$/.test(v) ? v : null; +} + +// --------------------------------------------------------------------------- +// Pack directory ingestion +// --------------------------------------------------------------------------- + +/** + * Ingest one resolved pack directory: write the agent_packs row and every + * SKILL.md it contains. Used by both gstack and bmad detection paths. + */ +async function ingestPackDir( + db: PackScannerDb, + opts: { + packId: string; + harness: string; + installPath: string; + sourceUrl?: string | null; + version?: string | null; + }, +): Promise { + const real = safeRealpath(opts.installPath); + const installKind = isSymlink(opts.installPath) ? "symlink" : "directory"; + const remoteUrl = opts.sourceUrl || deriveGitRemoteUrl(real); + + await upsertPack(db, { + pack_id: opts.packId, + harness: opts.harness, + install_path: opts.installPath, + install_kind: installKind, + source_url: remoteUrl, + version: opts.version || null, + }); + + let skillCount = 0; + for (const skillFile of findSkillFiles(real)) { + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const dirName = path.basename(path.dirname(skillFile)); + const name = meta.name || dirName; + if (!name) continue; + await upsertSkill(db, { + skill_id: deterministicSkillId(opts.harness, opts.installPath, name), + pack_id: opts.packId, + harness: opts.harness, + install_path: skillFile, + name, + version: meta.version || null, + description: meta.description || null, + source_url: remoteUrl, + }); + skillCount++; + } + return skillCount; +} + +// --------------------------------------------------------------------------- +// scanGStack +// --------------------------------------------------------------------------- + +/** + * Detect GStack: look for `gstack` or `gstack-*` entries under each known + * skills root. + * + * Claude install is a single directory at ~/.claude/skills/gstack — one + * agent_packs row. + * + * Codex install is special: the gstack ./setup --host codex creates ONE + * symlink per skill under ~/.codex/skills/ (gstack-autoplan, gstack-ship, + * etc., ~46 entries), each pointing into the same upstream gstack repo's + * .agents/skills tree. They all share one logical install — so we collapse + * them into a single agent_packs row keyed on the codex skills root (rather + * than registering 46 install rows), and still ingest every linked SKILL.md + * into the skills table. + */ +export async function scanGStack( + db: PackScannerDb, +): Promise { + const results: ScanGStackResult = { installs: 0, skills: 0 }; + + // --- Claude --- + const claudeSkillsRoot = path.join(resolveClaudeHome(), "skills"); + for (const entry of safeReadDir(claudeSkillsRoot)) { + if (entry.name !== "gstack") continue; + const installPath = path.join(claudeSkillsRoot, entry.name); + const real = safeRealpath(installPath); + if (!findSkillFiles(real).length) continue; + const version = readGStackVersion(real); + const added = await ingestPackDir(db, { + packId: "gstack", + harness: "claude", + installPath, + version, + }); + results.installs += 1; + results.skills += added; + } + + // --- Codex --- + const codexSkillsRoot = path.join(resolveCodexHome(), "skills"); + const codexEntries = safeReadDir(codexSkillsRoot).filter( + (e) => + (e.isDirectory() || e.isSymbolicLink()) && + (e.name === "gstack" || e.name.startsWith("gstack-")), + ); + if (codexEntries.length > 0) { + let sourceUrl: string | null = null; + let version: string | null = null; + for (const e of codexEntries) { + const real = safeRealpath(path.join(codexSkillsRoot, e.name)); + let probe = real; + for (let i = 0; i < 5 && !version; i++) { + version = readGStackVersion(probe); + if (!sourceUrl) sourceUrl = deriveGitRemoteUrl(probe); + const next = path.dirname(probe); + if (next === probe) break; + probe = next; + } + if (version || sourceUrl) break; + } + await upsertPack(db, { + pack_id: "gstack", + harness: "codex", + install_path: codexSkillsRoot, + install_kind: "symlink", + source_url: sourceUrl, + version, + }); + results.installs += 1; + for (const e of codexEntries) { + const entryPath = path.join(codexSkillsRoot, e.name); + const real = safeRealpath(entryPath); + for (const skillFile of findSkillFiles(real)) { + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const dirName = path.basename(path.dirname(skillFile)); + const name = meta.name || dirName; + if (!name) continue; + await upsertSkill(db, { + skill_id: deterministicSkillId("codex", codexSkillsRoot, name), + pack_id: "gstack", + harness: "codex", + install_path: skillFile, + name, + version: meta.version || null, + description: meta.description || null, + source_url: sourceUrl, + }); + results.skills += 1; + } + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// BMad helpers +// --------------------------------------------------------------------------- + +/** + * Parse `marketplace.json` and confirm it's a BMad plugin (pre-v6 layout). + * Returns `{ version }` on match, null otherwise. + */ +function readBmadMarketplace( + dir: string, +): { version: string | null } | null { + const file = path.join(dir, ".claude-plugin", "marketplace.json"); + const content = safeReadFile(file); + if (!content) return null; + try { + const parsed = JSON.parse(content); + if (parsed && parsed.name === "bmad-method") { + return { version: parsed.version || null }; + } + } catch { + /* malformed JSON — non-fatal */ + } + return null; +} + +/** + * Parse the project-local BMad install manifest (`_bmad/_config/manifest.yaml`) + * to extract the installed version. + */ +function readBmadProjectManifest( + projectRoot: string, +): { version: string } | null { + const manifestPath = path.join( + projectRoot, + "_bmad", + "_config", + "manifest.yaml", + ); + const content = safeReadFile(manifestPath); + if (!content) return null; + const lines = content.split(/\r?\n/); + let inInstallation = false; + for (const raw of lines) { + if (/^installation:\s*$/.test(raw)) { + inInstallation = true; + continue; + } + if ( + inInstallation && + /^[A-Za-z_][^:]*:/.test(raw) && + !raw.startsWith(" ") + ) { + break; + } + if (inInstallation) { + const m = raw.match( + /^\s+version:\s*['"]?([0-9][0-9A-Za-z.\-+]*)['"]?\s*$/, + ); + if (m) return { version: m[1] }; + } + } + return null; +} + +/** + * Detect a BMad v6+ project install: project root has `.agents/skills/bmad-*` + * directories with SKILL.md files. + */ +function detectBmadProjectInstall( + projectRoot: string, +): { installPath: string; version: string | null } | null { + const skillsRoot = path.join(projectRoot, ".agents", "skills"); + if (!safeStat(skillsRoot)) return null; + const hasBmadSkill = safeReadDir(skillsRoot).some( + (e) => + (e.isDirectory() || e.isSymbolicLink()) && + e.name.startsWith("bmad-") && + safeStat(path.join(skillsRoot, e.name, "SKILL.md")), + ); + if (!hasBmadSkill) return null; + const manifest = readBmadProjectManifest(projectRoot); + return { + installPath: skillsRoot, + version: manifest ? manifest.version : null, + }; +} + +/** + * Ingest BMad v6+ skills from a project install path. Only pulls in + * directories whose name is prefixed "bmad-". + */ +async function ingestBmadProjectSkills( + db: PackScannerDb, + opts: { installPath: string; harness: string; version: string | null }, +): Promise { + await upsertPack(db, { + pack_id: "bmad-method", + harness: opts.harness, + install_path: opts.installPath, + install_kind: "directory", + source_url: "https://github.com/bmad-code-org/BMAD-METHOD", + version: opts.version || null, + }); + let skillCount = 0; + for (const entry of safeReadDir(opts.installPath)) { + if (!(entry.isDirectory() || entry.isSymbolicLink())) continue; + if (!entry.name.startsWith("bmad-")) continue; + const skillFile = path.join(opts.installPath, entry.name, "SKILL.md"); + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const name = meta.name || entry.name; + await upsertSkill(db, { + skill_id: deterministicSkillId(opts.harness, opts.installPath, name), + pack_id: "bmad-method", + harness: opts.harness, + install_path: skillFile, + name, + version: meta.version || opts.version || null, + description: meta.description || null, + source_url: "https://github.com/bmad-code-org/BMAD-METHOD", + }); + skillCount++; + } + return skillCount; +} + +// --------------------------------------------------------------------------- +// scanBmad +// --------------------------------------------------------------------------- + +/** + * Detect BMad across all known layouts: + * 1. v6+ per-project install via .agents/skills/bmad-* (current) + * 2. Legacy global install via ~/.claude/skills//.claude-plugin/ + * marketplace.json + * 3. Legacy per-project install via _bmad/ directory (very old) + */ +export async function scanBmad( + db: PackScannerDb, +): Promise { + const results: ScanBmadResult = { installs: 0, skills: 0, projects: 0 }; + + // 1. Legacy global installs under ~/.claude/skills//.claude-plugin/ + const claudeSkillsRoot = path.join(resolveClaudeHome(), "skills"); + for (const entry of safeReadDir(claudeSkillsRoot)) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const installPath = path.join(claudeSkillsRoot, entry.name); + const marketplace = readBmadMarketplace(safeRealpath(installPath)); + if (!marketplace) continue; + const added = await ingestPackDir(db, { + packId: "bmad-method", + harness: "claude", + installPath, + version: marketplace.version, + }); + results.installs += 1; + results.skills += added; + } + + // 2 & 3. Per-project: walk distinct sessions.cwd from the last 90 days. + for (const projectRoot of await getRecentProjectRoots(db)) { + let added = 0; + let installedHere = false; + + // 2. BMad v6+: project-local `.agents/skills/bmad-*` install. Stamp the + // pack against both `claude` and `codex` harnesses because the manifest + // explicitly lists both as supported IDEs. + const v6 = detectBmadProjectInstall(projectRoot); + if (v6) { + for (const harness of ["claude", "codex"]) { + added += await ingestBmadProjectSkills(db, { + installPath: v6.installPath, + harness, + version: v6.version, + }); + results.installs += 1; + } + installedHere = true; + } + + // 3. Legacy per-project `_bmad/` install with marketplace.json upstream. + const legacyBmadDir = path.join(projectRoot, "_bmad"); + if (safeStat(legacyBmadDir) && !v6) { + let marketplace: { version: string | null } | null = null; + let probe = projectRoot; + for (let i = 0; i < 4 && !marketplace; i++) { + marketplace = readBmadMarketplace(probe); + probe = path.dirname(probe); + if (probe === path.dirname(probe)) break; // hit fs root + } + added += await ingestPackDir(db, { + packId: "bmad-method", + harness: "claude", + installPath: legacyBmadDir, + version: marketplace ? marketplace.version : null, + }); + results.installs += 1; + installedHere = true; + } + + if (installedHere) { + await upsertProjectAssociation(db, { + project_path: projectRoot, + pack_id: "bmad-method", + }); + results.skills += added; + results.projects += 1; + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// scanClaudeMarketplaces +// --------------------------------------------------------------------------- + +/** + * Detect plugins installed via Claude Code's marketplace system. The + * canonical install registry lives at ~/.claude/plugins/installed_plugins.json. + * + * Each marketplace becomes ONE pack (pack_id = marketplace name). Each + * plugin from that marketplace becomes one install row under the pack with + * its own per-plugin version. Skills aggregate across all plugins. + */ +export async function scanClaudeMarketplaces( + db: PackScannerDb, +): Promise { + const registryPath = path.join( + resolveClaudeHome(), + "plugins", + "installed_plugins.json", + ); + const raw = safeReadFile(registryPath); + if (!raw) return { installs: 0, skills: 0, marketplaces: 0 }; + let registry: { plugins?: Record }; + try { + registry = JSON.parse(raw); + } catch { + return { installs: 0, skills: 0, marketplaces: 0 }; + } + if (!registry || typeof registry.plugins !== "object") { + return { installs: 0, skills: 0, marketplaces: 0 }; + } + + // Group installed plugins by marketplace name. + const byMarketplace = new Map< + string, + Array<{ pluginName: string; installPath: string; version: string | null }> + >(); + for (const [pluginRef, scopes] of Object.entries(registry.plugins!)) { + const at = pluginRef.lastIndexOf("@"); + if (at < 1) continue; + const pluginName = pluginRef.slice(0, at); + const marketplace = pluginRef.slice(at + 1); + if (!Array.isArray(scopes)) continue; + for (const entry of scopes) { + if ( + !entry || + typeof entry !== "object" || + !(entry as Record).installPath + ) + continue; + const e = entry as { installPath: string; version?: string }; + if (!byMarketplace.has(marketplace)) { + byMarketplace.set(marketplace, []); + } + byMarketplace.get(marketplace)!.push({ + pluginName, + installPath: e.installPath, + version: e.version || null, + }); + } + } + + // Pack IDs that already have dedicated scanners — skip them here so + // marketplace installs of gstack or bmad-method don't get double-counted. + const reservedPackIds = new Set(["gstack", "bmad-method"]); + + const results: ScanMarketplacesResult & { plugins: number } = { + installs: 0, + skills: 0, + marketplaces: 0, + plugins: 0, + }; + + for (const [marketplace, plugins] of byMarketplace.entries()) { + if (reservedPackIds.has(marketplace)) continue; + const sourceUrl = KNOWN_MARKETPLACE_SOURCES[marketplace] || null; + + if (MARKETPLACE_BUNDLE_AS_PACK.has(marketplace)) { + // BUNDLED PATH: closedloop-ai and friends — the marketplace IS the + // pack, sub-plugins are skills. + const cacheRoot = path.join( + resolveClaudeHome(), + "plugins", + "cache", + marketplace, + ); + await upsertPack(db, { + pack_id: marketplace, + harness: "claude", + install_path: cacheRoot, + install_kind: "directory", + source_url: sourceUrl, + version: null, + }); + results.installs += 1; + for (const plugin of plugins) { + if (!safeStat(plugin.installPath)) continue; + const skillsDir = path.join(plugin.installPath, "skills"); + for (const skillFile of findSkillFiles(skillsDir)) { + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const dirName = path.basename(path.dirname(skillFile)); + const name = meta.name || dirName; + if (!name) continue; + await upsertSkill(db, { + skill_id: deterministicSkillId("claude", cacheRoot, name), + pack_id: marketplace, + harness: "claude", + install_path: skillFile, + name, + version: meta.version || plugin.version || null, + description: meta.description || null, + source_url: sourceUrl, + }); + results.skills += 1; + } + } + results.marketplaces += 1; + continue; + } + + // PER-PLUGIN PATH: claude-plugins-official, superpowers-marketplace, etc. + for (const plugin of plugins) { + if (!safeStat(plugin.installPath)) continue; + if (reservedPackIds.has(plugin.pluginName)) continue; + await upsertPack(db, { + pack_id: plugin.pluginName, + harness: "claude", + install_path: plugin.installPath, + install_kind: "directory", + source_url: sourceUrl, + version: plugin.version, + }); + results.installs += 1; + results.plugins += 1; + const skillsDir = path.join(plugin.installPath, "skills"); + for (const skillFile of findSkillFiles(skillsDir)) { + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const dirName = path.basename(path.dirname(skillFile)); + const name = meta.name || dirName; + if (!name) continue; + await upsertSkill(db, { + skill_id: deterministicSkillId( + "claude", + plugin.installPath, + name, + ), + pack_id: plugin.pluginName, + harness: "claude", + install_path: skillFile, + name, + version: meta.version || plugin.version || null, + description: meta.description || null, + source_url: sourceUrl, + }); + results.skills += 1; + } + } + results.marketplaces += 1; + } + return results; +} + +// --------------------------------------------------------------------------- +// scanProjectGStackAssociations +// --------------------------------------------------------------------------- + +/** + * Scan recent project roots for `.gstack/conductor.json` markers and record + * per-project associations. + */ +export async function scanProjectGStackAssociations( + db: PackScannerDb, +): Promise { + let count = 0; + for (const projectRoot of await getRecentProjectRoots(db)) { + const marker = path.join(projectRoot, ".gstack", "conductor.json"); + if (!safeStat(marker)) continue; + await upsertProjectAssociation(db, { + project_path: projectRoot, + pack_id: "gstack", + }); + count++; + } + return count; +} + +// --------------------------------------------------------------------------- +// Catalog detection adapters (ported from catalog-detector.js) +// --------------------------------------------------------------------------- + +async function detectVoltagentSubagents(db: PackScannerDb): Promise { + const root = path.join(resolveClaudeHome(), "skills", "voltagent-subagents"); + if (!safeStat(root)) return false; + await upsertPack(db, { + pack_id: "voltagent-subagents", + harness: "claude", + install_path: root, + install_kind: "directory", + source_url: "https://github.com/VoltAgent/awesome-claude-code-subagents", + version: null, + }); + // VoltAgent uses .md (NOT SKILL.md). Walk categories/ for agent files. + const categoriesDir = path.join(root, "categories"); + if (safeStat(categoriesDir)) { + for (const cat of safeReadDir(categoriesDir)) { + if (!cat.isDirectory()) continue; + const catDir = path.join(categoriesDir, cat.name); + for (const agent of safeReadDir(catDir)) { + if (!agent.isFile() || !agent.name.endsWith(".md")) continue; + if (agent.name.toUpperCase().startsWith("README")) continue; + const name = path.basename(agent.name, ".md"); + await upsertSkill(db, { + skill_id: deterministicSkillId("claude", root, name), + pack_id: "voltagent-subagents", + harness: "claude", + install_path: path.join(catDir, agent.name), + name, + version: null, + description: null, + source_url: + "https://github.com/VoltAgent/awesome-claude-code-subagents", + }); + } + } + } + return true; +} + +async function detectAlirezaSkills(db: PackScannerDb): Promise { + const root = path.join( + resolveClaudeHome(), + "skills", + "alirezarezvani-claude-skills", + ); + if (!safeStat(root)) return false; + await upsertPack(db, { + pack_id: "alirezarezvani-claude-skills", + harness: "claude", + install_path: root, + install_kind: "directory", + source_url: "https://github.com/alirezarezvani/claude-skills", + version: null, + }); + for (const skillFile of findSkillFiles(root)) { + const content = safeReadFile(skillFile); + if (content == null) continue; + const meta = parseSkillFrontmatter(content) || {}; + const dirName = path.basename(path.dirname(skillFile)); + const name = meta.name || dirName; + if (!name) continue; + await upsertSkill(db, { + skill_id: deterministicSkillId("claude", root, name), + pack_id: "alirezarezvani-claude-skills", + harness: "claude", + install_path: skillFile, + name, + version: meta.version || null, + description: meta.description || null, + source_url: "https://github.com/alirezarezvani/claude-skills", + }); + } + return true; +} + +async function detectSuperClaude(db: PackScannerDb): Promise { + // SuperClaude installs commands (NOT skills) into ~/.claude/commands/sc/. + const root = path.join(resolveClaudeHome(), "commands", "sc"); + if (!safeStat(root)) return false; + await upsertPack(db, { + pack_id: "superclaude", + harness: "claude", + install_path: root, + install_kind: "directory", + source_url: "https://github.com/SuperClaude-Org/SuperClaude_Framework", + version: null, + }); + for (const entry of safeReadDir(root)) { + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + if (entry.name.toUpperCase().startsWith("README")) continue; + const baseName = path.basename(entry.name, ".md"); + const name = `sc:${baseName}`; + await upsertSkill(db, { + skill_id: deterministicSkillId("claude", root, name), + pack_id: "superclaude", + harness: "claude", + install_path: path.join(root, entry.name), + name, + version: null, + description: null, + source_url: "https://github.com/SuperClaude-Org/SuperClaude_Framework", + }); + } + return true; +} + +async function detectClaudePluginsOfficial( + db: PackScannerDb, +): Promise { + const root = path.join( + resolveClaudeHome(), + "plugins", + "marketplaces", + "claude-plugins-official", + ); + if (!safeStat(root)) return false; + await upsertPack(db, { + pack_id: "claude-plugins-official", + harness: "claude", + install_path: root, + install_kind: "directory", + source_url: "https://github.com/anthropics/claude-plugins-official", + version: null, + }); + return true; +} + +/** + * Detect a binary-installed, harness-agnostic CLI tool. Since the binary works + * regardless of which agent harness invokes it, one detection registers an + * agent_packs row for EACH harness in `harnesses`. + */ +async function detectBinaryTool( + db: PackScannerDb, + opts: { + pack_id: string; + binNames: string[]; + source_url: string | null; + harnesses: string[]; + versionArgs?: string[]; + }, +): Promise { + let binaryPath: string | null = null; + for (const bin of opts.binNames) { + try { + const out = execFileSync("/usr/bin/which", [bin], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 1000, + }); + const trimmed = out.toString().trim(); + if (trimmed) { + binaryPath = trimmed; + break; + } + } catch { + /* not on PATH — try next bin name */ + } + } + if (!binaryPath) return false; + + let version: string | null = null; + if (Array.isArray(opts.versionArgs) && opts.versionArgs.length > 0) { + try { + const out = execFileSync(binaryPath, opts.versionArgs, { + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }); + const m = out.toString().match(/(\d+(?:\.\d+)+)/); + if (m) version = m[1]; + } catch { + /* version probe is best-effort */ + } + } + for (const harness of opts.harnesses) { + await upsertPack(db, { + pack_id: opts.pack_id, + harness, + install_path: binaryPath, + install_kind: "directory", // CHECK constraint allows symlink|directory + source_url: opts.source_url || null, + version, + }); + } + return true; +} + +async function detectRtk(db: PackScannerDb): Promise { + return detectBinaryTool(db, { + pack_id: "rtk", + binNames: ["rtk"], + source_url: "https://github.com/rtk-ai/rtk", + harnesses: ["claude", "codex"], + versionArgs: ["--version"], + }); +} + +async function detectClaudeCodeRouter(db: PackScannerDb): Promise { + // Global npm install — probe via `npm ls -g` (fast, no network). + let installed = false; + try { + execFileSync( + "npm", + ["ls", "-g", "--depth=0", "@musistudio/claude-code-router"], + { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ); + installed = true; + } catch { + // Fall back to probing the binary on PATH. + try { + // eslint-disable-next-line no-restricted-syntax + execFileSync("which", ["ccr"], { + stdio: ["ignore", "ignore", "ignore"], + timeout: 1000, + }); + installed = true; + } catch { + installed = false; + } + } + if (!installed) return false; + await upsertPack(db, { + pack_id: "claude-code-router", + harness: "claude", + install_path: "@musistudio/claude-code-router (npm -g)", + install_kind: "directory", + source_url: "https://github.com/musistudio/claude-code-router", + version: null, + }); + return true; +} + +// --------------------------------------------------------------------------- +// runCatalogDetectorAdapters +// --------------------------------------------------------------------------- + +type CatalogAdapter = [string, (db: PackScannerDb) => Promise]; + +const CATALOG_ADAPTERS: CatalogAdapter[] = [ + ["voltagent-subagents", detectVoltagentSubagents], + ["alirezarezvani-claude-skills", detectAlirezaSkills], + ["superclaude", detectSuperClaude], + ["claude-code-router", detectClaudeCodeRouter], + ["claude-plugins-official", detectClaudePluginsOfficial], + ["rtk", detectRtk], +]; + +/** + * Run the catalog-detector adapters (per-pack on-disk probes). + * Honors SKIP_CATALOG_DETECTORS=1 so unit tests can disable adapters that + * probe outside the fixture sandbox. + */ +export async function runCatalogDetectorAdapters( + db: PackScannerDb, +): Promise> { + if (process.env.SKIP_CATALOG_DETECTORS === "1") return {}; + const results: Record = {}; + for (const [name, fn] of CATALOG_ADAPTERS) { + try { + results[name] = await fn(db); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn(`[catalog-detector] adapter ${name} failed:`, msg); + results[name] = false; + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Prune stale rows +// --------------------------------------------------------------------------- + +/** + * Tombstone rows the current scan didn't observe. Packs/skills that USED to + * be installed are kept around with `uninstalled_at` set so the catalog can + * surface "previously installed, used N times" badges. + * project_pack_associations is still pruned (associations are observational). + */ +async function pruneStaleRows( + db: PackScannerDb, + scanStartedAt: string, +): Promise { + try { + await db.query( + `UPDATE agent_packs + SET uninstalled_at = $1 + WHERE last_seen_at < $2 + AND uninstalled_at IS NULL`, + [scanStartedAt, scanStartedAt], + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] tombstone agent_packs failed:", msg); + } + try { + await db.query( + `UPDATE skills + SET uninstalled_at = $1 + WHERE last_seen_at < $2 + AND uninstalled_at IS NULL`, + [scanStartedAt, scanStartedAt], + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] tombstone skills failed:", msg); + } + try { + await db.query( + "DELETE FROM project_pack_associations WHERE last_seen_at < $1", + [scanStartedAt], + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn( + "[pack-scanner] prune project_pack_associations failed:", + msg, + ); + } +} + +// --------------------------------------------------------------------------- +// runPackScanner — top-level orchestrator +// --------------------------------------------------------------------------- + +/** + * Top-level entry: run every scan path. Best-effort — exceptions in one branch + * never block another. Safe to call repeatedly. At the end, prune any + * inventory rows whose last_seen_at wasn't refreshed. Pruning is skipped when + * any detector fails so a transient error cannot tombstone real installs. + */ +export async function runPackScanner( + db: PackScannerDb, + overrides: RunPackScannerOverrides = {}, +): Promise { + const scanStartedAt = new Date().toISOString(); + const scanners = { + scanGStack: overrides.scanGStack || scanGStack, + scanBmad: overrides.scanBmad || scanBmad, + scanClaudeMarketplaces: + overrides.scanClaudeMarketplaces || scanClaudeMarketplaces, + scanProjectGStackAssociations: + overrides.scanProjectGStackAssociations || + scanProjectGStackAssociations, + runCatalogDetectorAdapters: + overrides.runCatalogDetectorAdapters || runCatalogDetectorAdapters, + }; + const summary: PackScannerSummary = { + gstack: { installs: 0, skills: 0 }, + bmad: { installs: 0, skills: 0, projects: 0 }, + marketplaces: { installs: 0, skills: 0, marketplaces: 0 }, + catalogDetectors: {}, + gstackProjects: 0, + prunedBefore: scanStartedAt, + scopes: { + gstack: false, + bmad: false, + marketplaces: false, + gstackProjects: false, + catalogDetectors: false, + }, + pruned: false, + pruneSkipped: false, + }; + + try { + summary.gstack = await scanners.scanGStack(db); + summary.scopes.gstack = true; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] gstack scan failed:", msg); + } + try { + summary.bmad = await scanners.scanBmad(db); + summary.scopes.bmad = true; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] bmad scan failed:", msg); + } + try { + summary.marketplaces = await scanners.scanClaudeMarketplaces(db); + summary.scopes.marketplaces = true; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] claude marketplace scan failed:", msg); + } + try { + summary.gstackProjects = + await scanners.scanProjectGStackAssociations(db); + summary.scopes.gstackProjects = true; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn( + "[pack-scanner] gstack project association scan failed:", + msg, + ); + } + try { + summary.catalogDetectors = + await scanners.runCatalogDetectorAdapters(db); + summary.scopes.catalogDetectors = true; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + // eslint-disable-next-line no-console + console.warn("[pack-scanner] catalog detectors failed:", msg); + } + + const allSucceeded = Object.values(summary.scopes).every(Boolean); + if (!allSucceeded) { + summary.pruneSkipped = true; + // eslint-disable-next-line no-console + console.warn( + "[pack-scanner] skipping prune — some detector scopes failed:", + Object.entries(summary.scopes) + .filter(([, ok]) => !ok) + .map(([k]) => k) + .join(", "), + ); + } else { + await pruneStaleRows(db, scanStartedAt); + summary.pruned = true; + } + return summary; +} + +// --------------------------------------------------------------------------- +// Test internals +// --------------------------------------------------------------------------- + +export const _internals = { + findSkillFiles, + readBmadMarketplace, + readBmadProjectManifest, + detectBmadProjectInstall, + readGStackVersion, + KNOWN_MARKETPLACE_SOURCES, + getRecentProjectRoots, + resolveClaudeHome, + resolveCodexHome, + detectVoltagentSubagents, + detectAlirezaSkills, + detectSuperClaude, + detectClaudeCodeRouter, + detectClaudePluginsOfficial, + detectRtk, + detectBinaryTool, +}; diff --git a/apps/desktop/src/main/packs/pack-store.ts b/apps/desktop/src/main/packs/pack-store.ts new file mode 100644 index 00000000..a87c41de --- /dev/null +++ b/apps/desktop/src/main/packs/pack-store.ts @@ -0,0 +1,646 @@ +/** + * @file pack-store.ts + * @description PGlite persistence for agent-pack inventory: installed packs + * (`agent_packs`), discovered skills (`skills`), and per-project markers + * (`project_pack_associations`). All three are pure inventory written by the + * filesystem scanner; invocation history is sourced from the existing `events` + * table and never duplicated here (FEA-1224 architectural constraint). + * + * Operates on the shared PGlite DB handle (async query API). Mirrors the + * structure of the original CJS pack-store.js with composite-key upserts in + * place of monotonic versioning. + * + * Schema lives in pglite.ts PGLITE_SCHEMA — no ensurePackSchema() here. + * + * Part of CLOSEDLOOP pack-observability (FEA-1224 / PLN-651, parent PRD-364). + */ + +import type { Results } from "@electric-sql/pglite"; + +type DbClient = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Upserts +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Upsert one `agent_packs` row keyed on (pack_id, harness, install_path). + * Updates `last_seen_at` plus the mutable fields (`version`, `source_url`, + * `install_kind`) but preserves the original `detected_at`. + */ +export async function upsertPack( + db: DbClient, + row: { + pack_id: string; + harness: string; + install_path: string; + install_kind: string; + source_url?: string | null; + version?: string | null; + }, +): Promise { + const ts = nowIso(); + await db.query( + `INSERT INTO agent_packs + (pack_id, harness, install_path, install_kind, source_url, version, detected_at, last_seen_at, uninstalled_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL) + ON CONFLICT(pack_id, harness, install_path) DO UPDATE SET + install_kind = excluded.install_kind, + source_url = COALESCE(excluded.source_url, agent_packs.source_url), + version = COALESCE(excluded.version, agent_packs.version), + last_seen_at = excluded.last_seen_at, + uninstalled_at = NULL`, + [ + row.pack_id, + row.harness, + row.install_path, + row.install_kind, + row.source_url || null, + row.version || null, + ts, + ts, + ], + ); +} + +/** + * Upsert one `skills` row keyed on `skill_id`. Callers compute `skill_id` + * deterministically (e.g. sha256 of harness|install_path|name) so re-scans + * dedupe to the same row. + */ +export async function upsertSkill( + db: DbClient, + row: { + skill_id: string; + pack_id?: string | null; + harness: string; + install_path: string; + name: string; + version?: string | null; + description?: string | null; + source_url?: string | null; + }, +): Promise { + const ts = nowIso(); + await db.query( + `INSERT INTO skills + (skill_id, pack_id, harness, install_path, name, version, description, source_url, detected_at, last_seen_at, uninstalled_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL) + ON CONFLICT(skill_id) DO UPDATE SET + pack_id = excluded.pack_id, + version = COALESCE(excluded.version, skills.version), + description = COALESCE(excluded.description, skills.description), + source_url = COALESCE(excluded.source_url, skills.source_url), + last_seen_at = excluded.last_seen_at, + uninstalled_at = NULL`, + [ + row.skill_id, + row.pack_id || null, + row.harness, + row.install_path, + row.name, + row.version || null, + row.description || null, + row.source_url || null, + ts, + ts, + ], + ); +} + +/** + * Upsert one `project_pack_associations` row keyed on (project_path, pack_id). + */ +export async function upsertProjectAssociation( + db: DbClient, + row: { project_path: string; pack_id: string }, +): Promise { + const ts = nowIso(); + await db.query( + `INSERT INTO project_pack_associations + (project_path, pack_id, detected_at, last_seen_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(project_path, pack_id) DO UPDATE SET + last_seen_at = excluded.last_seen_at`, + [row.project_path, row.pack_id, ts, ts], + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Reads +// ──────────────────────────────────────────────────────────────────────────── + +interface PackListRow extends Record { + pack_id: string; + version: string | null; + harnesses: string | null; + install_count: number; + first_detected_at: string; + last_seen_at: string; + skill_count: number; +} + +/** + * List all packs, collapsed to one row per `pack_id` (the user-facing handle). + * Includes harness fan-out and skill count. + * + * Installed-inventory reads filter uninstalled_at IS NULL so tombstoned + * rows (kept for retroactive usage attribution) do NOT surface as + * currently-installed. Re-installing a tombstoned pack clears + * uninstalled_at in the upsert path. + * + * version is NULL when the pack has multiple distinct install versions + * (e.g. a marketplace pack with several plugins at different versions) -- + * avoids picking one arbitrary value and presenting it as authoritative. + */ +export async function listPacks(db: DbClient): Promise { + const result = await db.query( + `SELECT + p.pack_id, + CASE + WHEN COUNT(DISTINCT COALESCE(p.version, '')) > 1 THEN NULL + ELSE MAX(p.version) + END AS version, + string_agg(DISTINCT p.harness, ',') AS harnesses, + COUNT(DISTINCT p.harness || '|' || p.install_path) AS install_count, + MIN(p.detected_at) AS first_detected_at, + MAX(p.last_seen_at) AS last_seen_at, + (SELECT COUNT(*) + FROM skills s + WHERE s.pack_id = p.pack_id + AND s.uninstalled_at IS NULL) AS skill_count + FROM agent_packs p + WHERE p.uninstalled_at IS NULL + GROUP BY p.pack_id + ORDER BY p.pack_id ASC`, + ); + return result.rows; +} + +interface PackInstallRow extends Record { + pack_id: string; + harness: string; + install_path: string; + install_kind: string; + source_url: string | null; + version: string | null; + detected_at: string; + last_seen_at: string; +} + +interface SkillRow extends Record { + skill_id: string; + pack_id: string | null; + harness: string; + install_path: string; + name: string; + version: string | null; + description: string | null; + source_url: string | null; + detected_at: string; + last_seen_at: string; +} + +interface ProjectAssociationRow extends Record { + project_path: string; + pack_id: string; + detected_at: string; + last_seen_at: string; +} + +interface PackDetail { + pack_id: string; + version: string | null; + harnesses: string[]; + installs: PackInstallRow[]; + skills: SkillRow[]; + associations: ProjectAssociationRow[]; +} + +/** + * Get one pack by `pack_id`, returning installs (one row per harness/install + * path), skills, and project associations. Tombstoned installs are excluded. + */ +export async function getPack( + db: DbClient, + packId: string, +): Promise { + const installResult = await db.query( + `SELECT pack_id, harness, install_path, install_kind, source_url, version, + detected_at, last_seen_at + FROM agent_packs + WHERE pack_id = $1 + AND uninstalled_at IS NULL + ORDER BY harness ASC, install_path ASC`, + [packId], + ); + const installs = installResult.rows; + if (!installs.length) return null; + + const skills = await listSkillsForPack(db, packId); + + const assocResult = await db.query( + `SELECT project_path, pack_id, detected_at, last_seen_at + FROM project_pack_associations + WHERE pack_id = $1 + ORDER BY last_seen_at DESC`, + [packId], + ); + + return { + pack_id: packId, + version: installs[0].version, + harnesses: [...new Set(installs.map((i) => i.harness))], + installs, + skills, + associations: assocResult.rows, + }; +} + +export async function listSkillsForPack( + db: DbClient, + packId: string, +): Promise { + const result = await db.query( + `SELECT skill_id, pack_id, harness, install_path, name, version, description, + source_url, detected_at, last_seen_at + FROM skills + WHERE pack_id IS NOT DISTINCT FROM $1 + AND uninstalled_at IS NULL + ORDER BY name ASC, harness ASC`, + [packId], + ); + return result.rows; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Skill invocation queries +// ──────────────────────────────────────────────────────────────────────────── + +// Shared SQL fragment: extract the skill-name token from a UserPromptSubmit +// event's `data` field (stored as TEXT, cast to jsonb). Claude Code records +// slash-command invocations as UserPromptSubmit events where +// data->'prompt' = "/ [args...]" (no PreToolUse / tool_name='Skill' +// event is fired). We pull the first whitespace-delimited token after the +// leading slash. Path-like prompts (e.g. "/Users/foo/...") are filtered out +// by requiring the extracted token to contain no slash characters. +// +// PG equivalent of the SQLite `instr / substr / json_extract` pattern: +// - json_extract(data,'$.prompt') → (data::jsonb->>'prompt') +// - instr(x, y) → position(y in x) +// - substr(x, a, b) → substring(x from a for b) +function skillNameFromPromptSql(tableAlias: string): string { + const prompt = `(${tableAlias}.data::jsonb->>'prompt')`; + const tail = `substring(${prompt} from 2)`; // strip leading '/' + return ` + CASE + WHEN position(' ' in ${tail}) > 0 + THEN substring(${tail} from 1 for position(' ' in ${tail}) - 1) + ELSE ${tail} + END`; +} + +interface SkillWithInvocations extends SkillRow { + invocation_count: number; + last_invoked_at: string | null; +} + +/** + * Cross-pack skills aggregate joined against the existing `events` table for + * invocation counts. Slash-command invocations are recorded by Claude Code's + * hook pipeline as `events` rows with `event_type='UserPromptSubmit'` and + * `data.prompt` of the form `/ [args...]` -- NOT as + * `PreToolUse`/`Skill` (those only fire for the tools the skill USES). + * + * Aggregation is partitioned by harness (joined from `sessions.harness`) so a + * pack installed for multiple harnesses (e.g. gstack for Claude AND Codex) + * reports each install row with its own count rather than attributing every + * call to every install. `sessions.harness` is the SoT for which harness + * fired a given hook event -- it has been on the schema since the FEA-1132 + * Codex patch (default 'claude' for legacy rows). + */ +export async function listSkills(db: DbClient): Promise { + const result = await db.query( + `SELECT + s.skill_id, + s.pack_id, + s.harness, + s.install_path, + s.name, + s.version, + s.description, + s.source_url, + s.detected_at, + s.last_seen_at, + COALESCE(inv.invocation_count, 0)::int AS invocation_count, + inv.last_invoked_at AS last_invoked_at + FROM skills s + LEFT JOIN ( + SELECT + ${skillNameFromPromptSql("e")} AS skill_name, + COALESCE(NULLIF(sess.harness, ''), 'claude') AS harness, + COUNT(*)::int AS invocation_count, + MAX(e.created_at) AS last_invoked_at + FROM events e + JOIN sessions sess ON sess.id = e.session_id + WHERE e.event_type = 'UserPromptSubmit' + AND (e.data::jsonb->>'prompt') LIKE '/_%' + GROUP BY skill_name, harness + ) inv ON inv.skill_name = s.name AND inv.harness = s.harness + WHERE s.uninstalled_at IS NULL + ORDER BY (s.pack_id IS NULL) ASC, s.pack_id ASC, s.name ASC, s.harness ASC`, + ); + return result.rows; +} + +interface SkillInvocationRow extends Record { + event_id: string; + session_id: string; + created_at: string; + summary: string | null; + data: string | null; + session_name: string | null; + session_cwd: string | null; + session_harness: string; + session_model: string | null; +} + +/** + * Recent invocations for one skill name, joined to `sessions` for session + * labels, cwd, harness, and model. Pulls from the `events` table only -- no + * parallel invocation storage exists. Same UserPromptSubmit pattern as + * listSkills. The optional `harness` filter restricts results to a single + * install row's calls -- needed so the Skills page detail panel shows only + * the calls that match the install row the user clicked on. + */ +export async function listSkillInvocations( + db: DbClient, + name: string, + { + limit = 50, + offset = 0, + harness = null as string | null, + } = {}, +): Promise { + const harnessClause = harness + ? "AND COALESCE(NULLIF(sess.harness, ''), 'claude') = $2" + : ""; + + const params: unknown[] = [name]; + if (harness) params.push(harness); + // limit and offset positions depend on whether harness is present + const limitIdx = params.length + 1; + const offsetIdx = params.length + 2; + params.push(limit, offset); + + const prompt = `(e.data::jsonb->>'prompt')`; + const tail = `substring(${prompt} from 2)`; + + const result = await db.query( + `SELECT + e.id AS event_id, + e.session_id, + e.created_at, + e.summary, + e.data, + sess.name AS session_name, + sess.cwd AS session_cwd, + COALESCE(NULLIF(sess.harness, ''), 'claude') AS session_harness, + sess.model AS session_model + FROM events e + JOIN sessions sess ON sess.id = e.session_id + WHERE e.event_type = 'UserPromptSubmit' + AND ${prompt} LIKE '/_%' + AND ( + CASE + WHEN position(' ' in ${tail}) > 0 + THEN substring(${tail} from 1 for position(' ' in ${tail}) - 1) + ELSE ${tail} + END + ) = $1 + ${harnessClause} + ORDER BY e.created_at DESC + LIMIT $${limitIdx} OFFSET $${offsetIdx}`, + params, + ); + return result.rows; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Pack path collection & usage attribution +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Collect per-pack detection-path patterns from three sources: + * 1. `agent_packs.install_path` -- current AND tombstoned installs. + * 2. `project_pack_associations.project_path` -- per-project installs like + * BMad's `_bmad/` directory. + * 3. `pack_catalog.detection_patterns` (optional) -- seeded fuzzy patterns + * for packs invoked via plugins-cache or other path shapes that don't + * have a formal install row. Catches packs that were used but never + * formally installed in `agent_packs`. + * + * Returns Map. + */ +export async function collectPackPaths( + db: DbClient, +): Promise> { + const out = new Map>(); + + function add(pack_id: string | null, p: unknown): void { + if (!pack_id || typeof p !== "string" || !p) return; + if (!out.has(pack_id)) out.set(pack_id, new Set()); + out.get(pack_id)!.add(p); + } + + const installRows = await db.query<{ + pack_id: string; + install_path: string; + }>( + "SELECT pack_id, install_path FROM agent_packs WHERE install_path IS NOT NULL", + ); + for (const row of installRows.rows) { + add(row.pack_id, row.install_path); + } + + const assocRows = await db.query<{ + pack_id: string; + project_path: string; + }>( + "SELECT pack_id, project_path FROM project_pack_associations WHERE project_path IS NOT NULL", + ); + for (const row of assocRows.rows) { + add(row.pack_id, row.project_path); + } + + // detection_patterns is on the catalog table -- may not exist in legacy/test + // environments. try/catch keeps this best-effort. + try { + const catalogRows = await db.query<{ + pack_id: string; + detection_patterns: unknown; + }>( + "SELECT pack_id, detection_patterns FROM pack_catalog WHERE detection_patterns IS NOT NULL", + ); + for (const row of catalogRows.rows) { + // detection_patterns is JSONB in PGlite, so it comes back as a parsed + // value (array) rather than a string that needs JSON.parse. + let patterns: unknown[]; + if (Array.isArray(row.detection_patterns)) { + patterns = row.detection_patterns; + } else if (typeof row.detection_patterns === "string") { + try { + patterns = JSON.parse(row.detection_patterns); + } catch { + continue; + } + if (!Array.isArray(patterns)) continue; + } else { + continue; + } + for (const p of patterns) add(row.pack_id, p); + } + } catch { + /* pack_catalog table missing -- non-fatal */ + } + + // Convert Set values to arrays so callers can map over them. + const result = new Map(); + for (const [k, v] of out) result.set(k, Array.from(v)); + return result; +} + +interface PackUsageRow extends Record { + pack_id: string; + tool_calls: number; + sessions: number; + first_used_at: string; + last_used_at: string; +} + +/** + * Retroactive pack-usage attribution from the existing `events` table. + * See `collectPackPaths()` for which path sources are joined. + * + * Returns one row per pack_id with: tool-call count, distinct sessions, + * first/last used timestamps. Includes tombstoned (uninstalled) packs so they + * still surface as "previously installed, used N times" on the catalog grid. + */ +export async function listPackUsage(db: DbClient): Promise { + const byPack = await collectPackPaths(db); + if (byPack.size === 0) return []; + + const out: PackUsageRow[] = []; + for (const [packId, packPaths] of byPack) { + const likeClauses = packPaths.map((_, i) => `e.data LIKE $${i + 1}`).join(" OR "); + const likeParams = packPaths.map((p) => `%${p}%`); + const result = await db.query<{ + tool_calls: number; + sessions: number; + first_used_at: string; + last_used_at: string; + }>( + `SELECT + COUNT(*)::int AS tool_calls, + COUNT(DISTINCT e.session_id)::int AS sessions, + MIN(e.created_at) AS first_used_at, + MAX(e.created_at) AS last_used_at + FROM events e + WHERE ${likeClauses}`, + likeParams, + ); + const row = result.rows[0] ?? null; + if (row && row.tool_calls > 0) { + out.push({ pack_id: packId, ...row }); + } + } + return out; +} + +interface PackSessionRow extends Record { + session_id: string; + session_name: string | null; + session_cwd: string | null; + session_harness: string; + session_model: string | null; + session_started_at: string | null; + tool_calls: number; + first_used_at: string; + last_used_at: string; +} + +/** + * Per-session usage rollup for one pack. Powers the "Used in N sessions" + * table on the Pack detail page. Each row is one session whose events touched + * one or more of the pack's detection paths (see `collectPackPaths()`). + * + * Sorted by last activity in that session, descending. + */ +export async function listPackSessions( + db: DbClient, + packId: string, + { limit = 25, offset = 0 } = {}, +): Promise { + const byPack = await collectPackPaths(db); + const packPaths = byPack.get(packId); + if (!packPaths || packPaths.length === 0) return []; + + const likeClauses = packPaths.map((_, i) => `e.data LIKE $${i + 1}`).join(" OR "); + const likeParams: unknown[] = packPaths.map((p) => `%${p}%`); + + const limitIdx = likeParams.length + 1; + const offsetIdx = likeParams.length + 2; + likeParams.push(limit, offset); + + const result = await db.query( + `SELECT + e.session_id, + sess.name AS session_name, + sess.cwd AS session_cwd, + COALESCE(NULLIF(sess.harness, ''), 'claude') AS session_harness, + sess.model AS session_model, + sess.started_at AS session_started_at, + COUNT(*)::int AS tool_calls, + MIN(e.created_at) AS first_used_at, + MAX(e.created_at) AS last_used_at + FROM events e + JOIN sessions sess ON sess.id = e.session_id + WHERE ${likeClauses} + GROUP BY e.session_id, sess.name, sess.cwd, sess.harness, sess.model, sess.started_at + ORDER BY last_used_at DESC + LIMIT $${limitIdx} OFFSET $${offsetIdx}`, + likeParams, + ); + return result.rows; +} + +/** Total count of sessions matching listPackSessions (for pagination UIs). */ +export async function countPackSessions( + db: DbClient, + packId: string, +): Promise { + const byPack = await collectPackPaths(db); + const packPaths = byPack.get(packId); + if (!packPaths || packPaths.length === 0) return 0; + + const likeClauses = packPaths.map((_, i) => `e.data LIKE $${i + 1}`).join(" OR "); + const likeParams = packPaths.map((p) => `%${p}%`); + + const result = await db.query<{ n: number }>( + `SELECT COUNT(DISTINCT e.session_id)::int AS n + FROM events e + WHERE ${likeClauses}`, + likeParams, + ); + const row = result.rows[0] ?? null; + return row ? row.n : 0; +} diff --git a/apps/desktop/src/main/plans/plan-store.ts b/apps/desktop/src/main/plans/plan-store.ts new file mode 100644 index 00000000..274e5321 --- /dev/null +++ b/apps/desktop/src/main/plans/plan-store.ts @@ -0,0 +1,873 @@ +/** + * @file plan-store.ts + * @description PGlite persistence + extraction for captured plans. Combines + * the old plan-store.js, plan-extractor.js, and plan-backfill.js into a single + * first-party ESM module for the design-system dashboard runtime. + * + * Schema lives in PGLITE_SCHEMA (pglite.ts) — no schema creation here. + * All DB calls use the PGlite async query API with positional $N params. + * + * Part of CLOSEDLOOP plan-extraction (FEA-1189 / PLN-613). + */ + +import { createHash, randomUUID } from "node:crypto"; +import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { Results } from "@electric-sql/pglite"; + +type DbClient = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowIso(): string { + return new Date().toISOString(); +} + +function uuid(): string { + return randomUUID(); +} + +function sha256(text: string | null | undefined): string { + return createHash("sha256") + .update(String(text == null ? "" : text).trim()) + .digest("hex"); +} + +function nonEmpty(s: unknown): s is string { + return typeof s === "string" && s.trim().length > 0; +} + +// --------------------------------------------------------------------------- +// Plan key derivation (from plan-store.js) +// --------------------------------------------------------------------------- + +function firstPlanLine(markdown: string | null | undefined): string | null { + if (typeof markdown !== "string") return null; + for (const rawLine of markdown.split(/\r?\n/)) { + const line = rawLine.replace(/^\s{0,3}#+\s*/, "").trim(); + if (line) return line.slice(0, 120); + } + return null; +} + +function normalizePlanKeyPart(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return normalized.length > 0 ? normalized : null; +} + +export function planKeyFor(capture: PlanCapture): string { + if (capture.file_path) { + const base = String(capture.file_path) + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + if (base) return base; + } + const keyPart = + normalizePlanKeyPart(firstPlanLine(capture.content_markdown)) || + normalizePlanKeyPart(capture.title) || + normalizePlanKeyPart(capture.source) || + "plan"; + const sessionKey = capture.created_from_session_id || "nosession"; + if (capture.harness === "codex") { + return `${sessionKey}:codex:${keyPart}`; + } + return `${sessionKey}:${keyPart}`; +} + +// --------------------------------------------------------------------------- +// Title extraction (from plan-extractor.js) +// --------------------------------------------------------------------------- + +export function titleFromMarkdown( + markdown: string | null | undefined, + fallback: string, +): string { + if (typeof markdown === "string") { + for (const rawLine of markdown.split("\n", 40)) { + const m = /^\s{0,3}#\s+(.+?)\s*#*\s*$/.exec(rawLine); + if (m && m[1].trim()) return m[1].trim().slice(0, 200); + } + } + return fallback; +} + +// --------------------------------------------------------------------------- +// Plan file path detection (from plan-extractor.js) +// --------------------------------------------------------------------------- + +export function isPlanFilePath(filePath: unknown): boolean { + if (typeof filePath !== "string" || filePath.length === 0) return false; + const norm = filePath.replace(/\\/g, "/"); + return norm.includes("/.claude/plans/"); +} + +function basenameNoExt(filePath: string | null | undefined): string | null { + if (typeof filePath !== "string") return null; + const base = filePath + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + if (!base) return null; + return base.replace(/\.mdx?$/i, ""); +} + +// --------------------------------------------------------------------------- +// PlanCapture shape (the normalized object that all extraction paths emit) +// --------------------------------------------------------------------------- + +export interface PlanCapture { + harness: string; + source: string; + capture_method: string; + created_from_session_id: string | null; + title: string; + file_path: string | null; + source_log_path: string | null; + content_markdown: string; + content_sha256: string; + confidence: number; + needs_confirmation: boolean; + source_event_ref: string | null; + captured_at: string | null; +} + +export function makeCapture(opts: { + harness: string; + source: string; + captureMethod: string; + sessionId: string | null; + content: string | null | undefined; + filePath?: string | null; + sourceLogPath?: string | null; + confidence: number; + sourceEventRef?: string | null; + capturedAt?: string | null; +}): PlanCapture { + const contentMarkdown = String(opts.content == null ? "" : opts.content); + const title = titleFromMarkdown( + contentMarkdown, + basenameNoExt(opts.filePath ?? null) || `Plan (${opts.source})`, + ); + return { + harness: opts.harness, + source: opts.source, + capture_method: opts.captureMethod, + created_from_session_id: opts.sessionId || null, + title, + file_path: opts.filePath ?? null, + source_log_path: opts.sourceLogPath ?? null, + content_markdown: contentMarkdown, + content_sha256: sha256(contentMarkdown), + confidence: opts.confidence, + needs_confirmation: opts.confidence < 0.9, + source_event_ref: opts.sourceEventRef ?? null, + captured_at: opts.capturedAt ?? null, + }; +} + +// --------------------------------------------------------------------------- +// Extraction: from session objects (import/watch path) +// --------------------------------------------------------------------------- + +const PROPOSED_PLAN_RE = /([\s\S]*?)<\/proposed_plan>/i; + +export function extractProposedPlanText(text: unknown): string | null { + if (typeof text !== "string") return null; + const m = PROPOSED_PLAN_RE.exec(text); + const inner = m && m[1] ? m[1].trim() : ""; + return inner.length > 0 ? inner : null; +} + +function deriveClaudeTranscriptPath( + cwd: string | null | undefined, + sessionId: string | null | undefined, +): string | null { + if (!cwd || !sessionId) return null; + try { + const home = process.env.CLAUDE_HOME || join(homedir(), ".claude"); + const slug = String(cwd).replace(/[/.]/g, "-"); + const p = join(home, "projects", slug, `${sessionId}.jsonl`); + return existsSync(p) ? p : null; + } catch { + return null; + } +} + +interface ToolUseInput { + plan?: string; + planFilePath?: string; + plan_file_path?: string; + planFile?: string; + file_path?: string; + content?: string; +} + +interface ToolUseEntry { + name?: string; + input?: ToolUseInput | null; + timestamp?: string; +} + +interface CodexPlan { + source?: string; + content?: string; + timestamp?: string; +} + +interface NormalizedSessionForPlans { + sessionId?: string | null; + cwd?: string | null; + toolUses?: ToolUseEntry[]; + plans?: CodexPlan[]; +} + +export function extractPlansFromSession( + session: NormalizedSessionForPlans | null | undefined, + captureMethod: "log" | "import" = "log", +): PlanCapture[] { + if (!session || typeof session !== "object") return []; + const sessionId = session.sessionId || null; + const out: PlanCapture[] = []; + const claudeLog = deriveClaudeTranscriptPath(session.cwd, sessionId); + + // Claude Code: ExitPlanMode + plans-dir Write tool_use blocks + const toolUses = Array.isArray(session.toolUses) ? session.toolUses : []; + for (const tu of toolUses) { + if (!tu || typeof tu !== "object") continue; + const input = + tu.input && typeof tu.input === "object" ? tu.input : null; + if (!input) continue; + + if (tu.name === "ExitPlanMode" && nonEmpty(input.plan)) { + out.push( + makeCapture({ + harness: "claude", + source: "claude-exitplanmode", + captureMethod, + sessionId, + content: input.plan, + filePath: + input.planFilePath || input.plan_file_path || input.planFile || null, + sourceLogPath: claudeLog, + confidence: 1.0, + sourceEventRef: `ExitPlanMode@${tu.timestamp || ""}`, + capturedAt: tu.timestamp || null, + }), + ); + } else if ( + tu.name === "Write" && + isPlanFilePath(input.file_path) && + nonEmpty(input.content) + ) { + out.push( + makeCapture({ + harness: "claude", + source: "claude-plan-write", + captureMethod, + sessionId, + content: input.content, + filePath: input.file_path, + sourceLogPath: claudeLog, + confidence: 1.0, + sourceEventRef: `Write@${tu.timestamp || ""}`, + capturedAt: tu.timestamp || null, + }), + ); + } + } + + // Codex: plan items surfaced by codex-parser into session.plans[] + const codexPlans = Array.isArray(session.plans) ? session.plans : []; + const structuredCodexPlanHashes = new Set( + codexPlans + .filter( + (cp): cp is CodexPlan => + cp != null && + typeof cp === "object" && + cp.source === "codex-plan-item" && + nonEmpty(cp.content), + ) + .map((cp) => sha256(cp.content)), + ); + for (const cp of codexPlans) { + if (!cp || typeof cp !== "object" || !nonEmpty(cp.content)) continue; + const isProposed = cp.source === "codex-proposed-plan"; + if (isProposed && structuredCodexPlanHashes.has(sha256(cp.content))) { + continue; + } + out.push( + makeCapture({ + harness: "codex", + source: cp.source || "codex-plan-item", + captureMethod, + sessionId, + content: cp.content, + filePath: null, + confidence: isProposed ? 0.6 : 1.0, + sourceEventRef: `${cp.source || "codex-plan"}@${cp.timestamp || ""}`, + capturedAt: cp.timestamp || null, + }), + ); + } + + return out; +} + +// --------------------------------------------------------------------------- +// Extraction: from live Claude Code hook payload +// --------------------------------------------------------------------------- + +interface HookEventData { + tool_name?: string; + tool_input?: ToolUseInput | null; + session_id?: string; + transcript_path?: string; +} + +export function extractPlanFromEvent( + data: HookEventData | null | undefined, + harness: string, +): { plan: PlanCapture } | null { + if (!data || typeof data !== "object") return null; + const toolName = data.tool_name; + const input = + data.tool_input && typeof data.tool_input === "object" + ? data.tool_input + : null; + if (!input) return null; + const sessionId = data.session_id || null; + const ts = new Date().toISOString(); + const logPath = + typeof data.transcript_path === "string" && data.transcript_path + ? data.transcript_path + : null; + + if (toolName === "ExitPlanMode" && nonEmpty(input.plan)) { + return { + plan: makeCapture({ + harness, + source: "claude-exitplanmode", + captureMethod: "hook", + sessionId, + content: input.plan, + filePath: + input.planFilePath || input.plan_file_path || input.planFile || null, + sourceLogPath: logPath, + confidence: 1.0, + sourceEventRef: `hook:ExitPlanMode@${ts}`, + capturedAt: ts, + }), + }; + } + if ( + toolName === "Write" && + isPlanFilePath(input.file_path) && + nonEmpty(input.content) + ) { + return { + plan: makeCapture({ + harness, + source: "claude-plan-write", + captureMethod: "hook", + sessionId, + content: input.content, + filePath: input.file_path, + sourceLogPath: logPath, + confidence: 1.0, + sourceEventRef: `hook:Write@${ts}`, + capturedAt: ts, + }), + }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Extraction: from ~/.claude/plans/ directory (file capture) +// --------------------------------------------------------------------------- + +export function extractPlansFromPlansDir(plansDir: string): PlanCapture[] { + const out: PlanCapture[] = []; + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(plansDir, { withFileTypes: true, encoding: "utf8" }); + } catch { + return out; + } + for (const ent of entries) { + if (!ent.isFile() || !/\.mdx?$/i.test(ent.name)) continue; + const fp = join(plansDir, ent.name); + let content: string; + let capturedAt: string | null = null; + try { + content = readFileSync(fp, "utf8"); + capturedAt = new Date(statSync(fp).mtimeMs).toISOString(); + } catch { + continue; + } + if (!nonEmpty(content)) continue; + out.push( + makeCapture({ + harness: "claude", + source: "claude-plan-file", + captureMethod: "file", + sessionId: null, + content, + filePath: fp, + confidence: 1.0, + sourceEventRef: `plansdir:${ent.name}`, + capturedAt, + }), + ); + } + return out; +} + +// --------------------------------------------------------------------------- +// DB: find existing plan row +// --------------------------------------------------------------------------- + +interface PlanRow extends Record { + id: string; + plan_key: string | null; + harness: string | null; + created_from_session_id: string | null; + file_path: string | null; + source_log_path: string | null; + updated_at: string | null; +} + +async function findExistingPlan( + db: DbClient, + capture: PlanCapture, + planKey: string, +): Promise { + const harness = capture.harness || null; + const sessionId = capture.created_from_session_id || null; + + if (capture.file_path) { + const result = await db.query( + `SELECT * FROM plans + WHERE harness IS NOT DISTINCT FROM $1 AND plan_key = $2 + AND (file_path = $3 OR file_path IS NULL) + ORDER BY CASE WHEN file_path = $4 THEN 0 ELSE 1 END, + CASE WHEN created_from_session_id IS NULL THEN 1 ELSE 0 END, + updated_at DESC + LIMIT 1`, + [harness, planKey, capture.file_path, capture.file_path], + ); + return result.rows[0] ?? null; + } + + const result = await db.query( + `SELECT * FROM plans + WHERE harness IS NOT DISTINCT FROM $1 + AND created_from_session_id IS NOT DISTINCT FROM $2 + AND plan_key = $3 + ORDER BY updated_at DESC + LIMIT 1`, + [harness, sessionId, planKey], + ); + return result.rows[0] ?? null; +} + +// --------------------------------------------------------------------------- +// DB: upsertPlan (upsertPlanCapture) +// --------------------------------------------------------------------------- + +interface UpsertPlanResult { + planId: string; + versionId: string | null; + version: number; + deduped: boolean; + created: boolean; +} + +export async function upsertPlan( + db: DbClient, + capture: PlanCapture, +): Promise { + const planKey = planKeyFor(capture); + const sessionId = capture.created_from_session_id || null; + const ts = capture.captured_at || nowIso(); + + const existingPlan = await findExistingPlan(db, capture, planKey); + + let planId: string; + let created = false; + + if (existingPlan) { + planId = existingPlan.id; + const latestResult = await db.query<{ + content_sha256: string; + version_number: number; + } & Record>( + `SELECT content_sha256, version_number + FROM plan_versions WHERE plan_id = $1 + ORDER BY version_number DESC LIMIT 1`, + [planId], + ); + const latest = latestResult.rows[0] ?? null; + + if (latest && latest.content_sha256 === capture.content_sha256) { + // Identical content — no-op for versioning, backfill links if missing. + if (capture.file_path || capture.source_log_path) { + await db.query( + `UPDATE plans + SET created_from_session_id = COALESCE(created_from_session_id, $1), + file_path = COALESCE(file_path, $2), + source_log_path = COALESCE(source_log_path, $3) + WHERE id = $4`, + [ + sessionId, + capture.file_path || null, + capture.source_log_path || null, + planId, + ], + ); + } else if (sessionId) { + await db.query( + `UPDATE plans + SET created_from_session_id = COALESCE(created_from_session_id, $1) + WHERE id = $2`, + [sessionId, planId], + ); + } + return { + planId, + versionId: null, + version: latest.version_number, + deduped: true, + created: false, + }; + } + } else { + planId = uuid(); + await db.query( + `INSERT INTO plans + (id, title, status, source, + capture_method, harness, created_from_session_id, created_from_event_id, + plan_key, file_path, source_log_path, needs_confirmation, confidence, + sync_state, metadata, created_at, updated_at) + VALUES ($1, $2, 'active', 'captured', $3, $4, $5, $6, $7, $8, $9, $10, $11, + 'local_only', NULL, $12, $13)`, + [ + planId, + capture.title || null, + capture.capture_method || null, + capture.harness || null, + sessionId, + capture.source_event_ref || null, + planKey, + capture.file_path || null, + capture.source_log_path || null, + capture.needs_confirmation, + capture.confidence, + ts, + ts, + ], + ); + created = true; + } + + // Determine next version number + const nextRow = await db.query<{ n: number } & Record>( + `SELECT COALESCE(MAX(version_number), 0) AS n + FROM plan_versions WHERE plan_id = $1`, + [planId], + ); + const versionNumber = (nextRow.rows[0]?.n ?? 0) + 1; + const versionId = uuid(); + + await db.query( + `INSERT INTO plan_versions + (id, plan_id, version_number, content_markdown, content_json, + content_sha256, author_type, author_user_id, source_session_id, + source_event_ref, capture_method, created_at) + VALUES ($1, $2, $3, $4, NULL, $5, 'agent', NULL, $6, $7, $8, $9)`, + [ + versionId, + planId, + versionNumber, + capture.content_markdown, + capture.content_sha256, + sessionId, + capture.source_event_ref || null, + capture.capture_method || null, + ts, + ], + ); + + // Refresh the plan's latest-capture signals. + await db.query( + `UPDATE plans + SET title = COALESCE($1, title), + capture_method = COALESCE($2, capture_method), + harness = COALESCE($3, harness), + created_from_session_id = COALESCE(created_from_session_id, $4), + file_path = COALESCE($5, file_path), + source_log_path = COALESCE($6, source_log_path), + needs_confirmation = $7, + confidence = $8, + updated_at = $9 + WHERE id = $10`, + [ + capture.title || null, + capture.capture_method || null, + capture.harness || null, + sessionId, + capture.file_path || null, + capture.source_log_path || null, + capture.needs_confirmation, + capture.confidence, + ts, + planId, + ], + ); + + return { + planId, + versionId, + version: versionNumber, + deduped: false, + created, + }; +} + +// --------------------------------------------------------------------------- +// DB: upsertPlanVersion +// --------------------------------------------------------------------------- + +export interface PlanVersionInput { + plan_id: string; + content_markdown: string; + content_sha256?: string; + author_type?: string; + author_user_id?: string | null; + source_session_id?: string | null; + source_event_ref?: string | null; + capture_method?: string | null; +} + +export async function upsertPlanVersion( + db: DbClient, + version: PlanVersionInput, +): Promise<{ versionId: string; versionNumber: number; deduped: boolean }> { + const contentSha = version.content_sha256 ?? sha256(version.content_markdown); + + // Check for dedup + const latestResult = await db.query<{ + content_sha256: string; + version_number: number; + } & Record>( + `SELECT content_sha256, version_number + FROM plan_versions WHERE plan_id = $1 + ORDER BY version_number DESC LIMIT 1`, + [version.plan_id], + ); + const latest = latestResult.rows[0] ?? null; + if (latest && latest.content_sha256 === contentSha) { + return { + versionId: "", + versionNumber: latest.version_number, + deduped: true, + }; + } + + const versionNumber = (latest?.version_number ?? 0) + 1; + const versionId = uuid(); + const ts = nowIso(); + + await db.query( + `INSERT INTO plan_versions + (id, plan_id, version_number, content_markdown, content_json, + content_sha256, author_type, author_user_id, source_session_id, + source_event_ref, capture_method, created_at) + VALUES ($1, $2, $3, $4, NULL, $5, $6, $7, $8, $9, $10, $11)`, + [ + versionId, + version.plan_id, + versionNumber, + version.content_markdown, + contentSha, + version.author_type ?? "agent", + version.author_user_id ?? null, + version.source_session_id ?? null, + version.source_event_ref ?? null, + version.capture_method ?? null, + ts, + ], + ); + + // Refresh the plan's updated_at timestamp. + await db.query( + `UPDATE plans SET updated_at = $1 WHERE id = $2`, + [ts, version.plan_id], + ); + + return { versionId, versionNumber, deduped: false }; +} + +// --------------------------------------------------------------------------- +// DB: list / get / count +// --------------------------------------------------------------------------- + +interface PlanListFilters { + sessionId?: string | null; + needsConfirmation?: boolean | null; + limit?: number; + offset?: number; +} + +function buildPlanListFilters(opts: PlanListFilters): { + clause: string; + params: unknown[]; + nextParam: number; +} { + const clauses: string[] = []; + const params: unknown[] = []; + let idx = 1; + if (opts.sessionId) { + clauses.push(`created_from_session_id = $${idx}`); + params.push(opts.sessionId); + idx++; + } + if (typeof opts.needsConfirmation === "boolean") { + clauses.push(`needs_confirmation = $${idx}`); + params.push(opts.needsConfirmation); + idx++; + } + return { + clause: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", + params, + nextParam: idx, + }; +} + +export async function listPlans( + db: DbClient, + opts: PlanListFilters = {}, +): Promise[]> { + const { limit = 100, offset = 0 } = opts; + const filters = buildPlanListFilters(opts); + const result = await db.query( + `SELECT * FROM plans${filters.clause} + ORDER BY updated_at DESC LIMIT $${filters.nextParam} OFFSET $${filters.nextParam + 1}`, + [...filters.params, limit, offset], + ); + return result.rows; +} + +export async function countPlans( + db: DbClient, + opts: Omit = {}, +): Promise { + const filters = buildPlanListFilters(opts); + const result = await db.query<{ c: number } & Record>( + `SELECT COUNT(*)::int AS c FROM plans${filters.clause}`, + filters.params, + ); + return result.rows[0]?.c ?? 0; +} + +export async function getPlanVersions( + db: DbClient, + planId: string, +): Promise[]> { + const result = await db.query( + `SELECT id, plan_id, version_number, content_markdown, content_sha256, + author_type, source_session_id, source_event_ref, capture_method, + created_at + FROM plan_versions WHERE plan_id = $1 + ORDER BY version_number ASC`, + [planId], + ); + return result.rows; +} + +export async function getPlan( + db: DbClient, + id: string, +): Promise<(Record & { versions: Record[] }) | null> { + const result = await db.query(`SELECT * FROM plans WHERE id = $1`, [id]); + const plan = result.rows[0]; + if (!plan) return null; + const versions = await getPlanVersions(db, id); + return { ...plan, versions }; +} + +// --------------------------------------------------------------------------- +// DB: confirm / reject +// --------------------------------------------------------------------------- + +export async function confirmPlan(db: DbClient, id: string): Promise { + const result = await db.query( + `UPDATE plans + SET needs_confirmation = FALSE, status = 'confirmed', updated_at = $1 + WHERE id = $2`, + [nowIso(), id], + ); + return (result.affectedRows ?? 0) > 0; +} + +export async function rejectPlan(db: DbClient, id: string): Promise { + const result = await db.query( + `UPDATE plans + SET needs_confirmation = FALSE, status = 'rejected', updated_at = $1 + WHERE id = $2`, + [nowIso(), id], + ); + return (result.affectedRows ?? 0) > 0; +} + +// --------------------------------------------------------------------------- +// Backfill: scan ~/.claude/plans/ and upsert (from plan-backfill.js) +// --------------------------------------------------------------------------- + +function resolveClaudeHome(): string { + return ( + process.env.CLAUDE_HOME || join(homedir(), ".claude") + ); +} + +export async function backfillPlansFromDirectory( + db: DbClient, + plansDir?: string, +): Promise<{ captured: number; deduped: number; errors: number }> { + const dir = plansDir ?? join(resolveClaudeHome(), "plans"); + let captured = 0; + let deduped = 0; + let errors = 0; + + for (const cap of extractPlansFromPlansDir(dir)) { + try { + const r = await upsertPlan(db, cap); + if (r.deduped) { + deduped += 1; + } else { + captured += 1; + } + } catch { + errors += 1; + } + } + + return { captured, deduped, errors }; +} diff --git a/apps/desktop/src/main/preload-design-system.ts b/apps/desktop/src/main/preload-design-system.ts index 56f3c5d9..875cd457 100644 --- a/apps/desktop/src/main/preload-design-system.ts +++ b/apps/desktop/src/main/preload-design-system.ts @@ -3,6 +3,7 @@ import type { AgentHierarchyNode, AgentRow, AnalyticsData, + CatalogEntry, DashboardCoreFeatures, DashboardPackSummary, DashboardPlanSummary, @@ -14,11 +15,21 @@ import type { EventCountByType, EventRow, EventWithSession, + InstallRunRecord, + InstalledPack, + InstalledPackDetail, KanbanPages, + PlanRecord, + PlanVersionRecord, + PrRecord, + PrSessionGroup, + PrStats, SessionPage, SessionPageRequest, SessionRow, SessionWithAgents, + SkillInvocation, + SkillWithInvocations, TokenAnalytics, WorkflowQueryData, } from "../shared/agent-db-contract.js"; @@ -49,6 +60,42 @@ const designSystemDashboardApi = { getSubAgents: () => ipcRenderer.invoke("desktop:db:get-subagents") as Promise, getPlans: () => ipcRenderer.invoke("desktop:db:get-plans") as Promise, getPullRequests: () => ipcRenderer.invoke("desktop:db:get-pull-requests") as Promise, + + // Catalog (FEA-1314) + getCatalog: () => ipcRenderer.invoke("desktop:db:get-catalog") as Promise, + getCatalogEntry: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-entry", packId) as Promise, + getCatalogReadme: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-readme", packId) as Promise, + getCatalogContents: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-contents", packId) as Promise, + getCatalogHistory: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-history", packId) as Promise>, + catalogInstall: (packId: string, harness: string, cwd?: string) => ipcRenderer.invoke("desktop:db:catalog-install", packId, harness, cwd) as Promise<{ runId: number }>, + catalogUninstall: (packId: string, harness: string) => ipcRenderer.invoke("desktop:db:catalog-uninstall", packId, harness) as Promise<{ runId: number }>, + catalogRefresh: () => ipcRenderer.invoke("desktop:db:catalog-refresh") as Promise, + getInstallRuns: (packId?: string) => ipcRenderer.invoke("desktop:db:get-install-runs", packId) as Promise, + + // Installed packs (FEA-1224) + getInstalledPacks: () => ipcRenderer.invoke("desktop:db:get-installed-packs") as Promise, + getPackDetail: (packId: string) => ipcRenderer.invoke("desktop:db:get-pack-detail", packId) as Promise, + getPackSessions: (packId: string) => ipcRenderer.invoke("desktop:db:get-pack-sessions", packId) as Promise, + getAllSkills: () => ipcRenderer.invoke("desktop:db:get-all-skills") as Promise, + getSkillInvocations: (name: string) => ipcRenderer.invoke("desktop:db:get-skill-invocations", name) as Promise, + getRecentProjects: () => ipcRenderer.invoke("desktop:db:get-recent-projects") as Promise, + + // Plans (FEA-1189) + getPlansList: (opts?: { sessionId?: string; needsConfirmation?: boolean; limit?: number; offset?: number }) => + ipcRenderer.invoke("desktop:db:get-plans-list", opts) as Promise, + getPlan: (id: string) => ipcRenderer.invoke("desktop:db:get-plan", id) as Promise, + getPlanVersions: (planId: string) => ipcRenderer.invoke("desktop:db:get-plan-versions", planId) as Promise, + confirmPlan: (id: string) => ipcRenderer.invoke("desktop:db:confirm-plan", id) as Promise, + rejectPlan: (id: string) => ipcRenderer.invoke("desktop:db:reject-plan", id) as Promise, + openPlan: (id: string, target?: string) => ipcRenderer.invoke("desktop:db:open-plan", id, target) as Promise, + + // Pull Requests (FEA-1226) + getPrStats: () => ipcRenderer.invoke("desktop:db:get-pr-stats") as Promise, + getPrSessions: (opts?: { limit?: number; offset?: number }) => + ipcRenderer.invoke("desktop:db:get-pr-sessions", opts) as Promise, + getPrList: (opts?: { sessionId?: string; repo?: string; limit?: number; offset?: number }) => + ipcRenderer.invoke("desktop:db:get-pr-list", opts) as Promise, + openPr: (id: string) => ipcRenderer.invoke("desktop:db:open-pr", id) as Promise, }, /** * Subscribe to in-process DB-change pushes. The design renderer listens for @@ -59,6 +106,13 @@ const designSystemDashboardApi = { ipcRenderer.on("desktop:db:changed", handler); return () => ipcRenderer.removeListener("desktop:db:changed", handler); }, + /** Subscribe to streamed pack install/uninstall output (FEA-1314). */ + onInstallOutput: (callback: (payload: { runId: number; type: string; data: string }) => void) => { + const handler = (_event: unknown, payload: { runId: number; type: string; data: string }) => + callback(payload); + ipcRenderer.on("desktop:pack:install-output", handler); + return () => ipcRenderer.removeListener("desktop:pack:install-output", handler); + }, }; exposeDesktopApi(designSystemDashboardApi); diff --git a/apps/desktop/src/main/pull-requests/pr-store.ts b/apps/desktop/src/main/pull-requests/pr-store.ts new file mode 100644 index 00000000..b9a3c9e9 --- /dev/null +++ b/apps/desktop/src/main/pull-requests/pr-store.ts @@ -0,0 +1,971 @@ +/** + * @file pr-store.ts + * @description PGlite persistence, extraction, and backfill for captured pull + * requests. Combines the old pull-request-store.js, pr-extractor.js, + * pr-parsers.js, and pr-backfill.js into a single first-party ESM module for + * the design-system dashboard runtime. + * + * Schema lives in PGLITE_SCHEMA (pglite.ts) — no schema creation here. + * All DB calls use the PGlite async query API with positional $N params. + * + * Part of CLOSEDLOOP engineer GitHub activity capture (FEA-1226). + */ + +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import type { Results } from "@electric-sql/pglite"; + +type DbClient = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowIso(): string { + return new Date().toISOString(); +} + +/** Deterministic 16-hex id — same PR in the same session dedups to one row. */ +function pullRequestId( + harness: string, + sessionId: string, + prUrl: string, +): string { + return createHash("sha256") + .update(`${harness}|${sessionId}|${prUrl}`) + .digest("hex") + .slice(0, 16); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// --------------------------------------------------------------------------- +// PR URL parsing (from pr-parsers.js) +// --------------------------------------------------------------------------- + +const GITHUB_PR_URL_RE = + /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(?!new\b)(\d+)/g; + +const FIXTURE_OWNER_RE = + /^(?:owner|acme|org|example|test-org|sample|fixtures?|placeholder|repo)$/i; + +const PENDING_COMMAND_CAP = 256; + +export function isFixtureOwner(owner: string): boolean { + return FIXTURE_OWNER_RE.test(owner); +} + +interface PrUrlRef { + prUrl: string; + prNumber: number; + repoFullName: string; + owner: string; +} + +export function extractPrUrlsFromText(text: unknown): PrUrlRef[] { + if (typeof text !== "string" || text.length === 0) return []; + const seen = new Set(); + const refs: PrUrlRef[] = []; + for (const match of text.matchAll(GITHUB_PR_URL_RE)) { + const owner = match[1]; + const repo = match[2]; + const prNumberRaw = match[3]; + if (!owner || !repo || !prNumberRaw) continue; + if (isFixtureOwner(owner)) continue; + const prNumber = Number.parseInt(prNumberRaw, 10); + if (!Number.isFinite(prNumber) || prNumber <= 0) continue; + const prUrl = `https://github.com/${owner}/${repo}/pull/${prNumber}`; + if (seen.has(prUrl)) continue; + seen.add(prUrl); + refs.push({ prUrl, prNumber, repoFullName: `${owner}/${repo}`, owner }); + } + return refs; +} + +export function isPrCreateCommand(cmd: unknown): boolean { + if (typeof cmd !== "string") return false; + return /(?:^|[;&|(\n\t])\s*(?:\S+=\S+\s+)*gh\s+pr\s+create(?:$|[\s'")])/.test( + cmd, + ); +} + +export function safeParseLine( + line: unknown, +): Record | null { + if (typeof line !== "string") return null; + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return null; + try { + return JSON.parse(trimmed) as Record; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Session parser state + line parser (from pr-parsers.js) +// --------------------------------------------------------------------------- + +interface SessionParserState { + claudeBashCommands: Map; + codexCallCommands: Map; + codexSessionId: string | null; +} + +export function createSessionParserState(): SessionParserState { + return { + claudeBashCommands: new Map(), + codexCallCommands: new Map(), + codexSessionId: null, + }; +} + +function flattenContent(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts: string[] = []; + for (const item of value) { + if (typeof item === "string") { + parts.push(item); + } else if (isRecord(item)) { + for (const key of ["text", "output", "content", "result"]) { + if (typeof item[key] === "string") parts.push(item[key] as string); + } + } + } + return parts.join("\n"); + } + return ""; +} + +function rememberCommand( + map: Map, + key: string, + command: string, +): void { + map.delete(key); + map.set(key, command); + if (map.size > PENDING_COMMAND_CAP) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } +} + +function extractCodexCommand(args: unknown): string { + if (typeof args !== "string") return ""; + try { + const parsed = JSON.parse(args); + if (isRecord(parsed) && typeof parsed.cmd === "string") + return parsed.cmd as string; + } catch { + /* arguments not JSON — fall through */ + } + return args; +} + +function extractParsedCmd(parsedCmd: unknown): string { + if (!Array.isArray(parsedCmd)) return ""; + return parsedCmd + .map((entry: unknown) => + isRecord(entry) && typeof entry.cmd === "string" + ? (entry.cmd as string) + : "", + ) + .filter((c: string) => c.length > 0) + .join(" && "); +} + +function extractHeadBranch(command: string): string | null { + const match = /--head[=\s]+(\S+)/.exec(command); + return match ? match[1] : null; +} + +// --------------------------------------------------------------------------- +// Per-harness line parsers (from pr-parsers.js) +// --------------------------------------------------------------------------- + +interface PrDraft { + prUrl: string; + prNumber: number; + repoFullName: string; + branchName: string | null; + headSha: string | null; + harness: string; + externalSessionId: string; + observedAt?: string; + title?: string | null; +} + +function loopEvent( + parsed: Record, + fallbackSessionId: string, +): PrDraft[] { + const prUrl = typeof parsed.prUrl === "string" ? parsed.prUrl : null; + if (!prUrl) return []; + const refs = extractPrUrlsFromText(prUrl); + if (refs.length === 0) return []; + const ref = refs[0]; + const sessionId = + typeof parsed.sessionId === "string" && parsed.sessionId + ? parsed.sessionId + : fallbackSessionId; + return [ + { + prUrl: ref.prUrl, + prNumber: ref.prNumber, + repoFullName: ref.repoFullName, + branchName: + typeof parsed.branchName === "string" ? parsed.branchName : null, + headSha: + typeof parsed.commitSha === "string" ? parsed.commitSha : null, + harness: "closedloop-loop", + externalSessionId: sessionId, + }, + ]; +} + +function claudeEvents( + parsed: Record, + fallbackSessionId: string, + state: SessionParserState, +): PrDraft[] { + const message = isRecord(parsed.message) ? parsed.message : null; + const content = + message && Array.isArray(message.content) ? message.content : []; + + if (parsed.type === "assistant") { + for (const block of content) { + if ( + !isRecord(block) || + block.type !== "tool_use" || + block.name !== "Bash" + ) + continue; + const id = typeof block.id === "string" ? block.id : null; + const input = isRecord(block.input) ? block.input : null; + const command = + input && typeof input.command === "string" + ? (input.command as string) + : ""; + if (id) rememberCommand(state.claudeBashCommands, id, command); + } + return []; + } + + const sessionId = + typeof parsed.sessionId === "string" && parsed.sessionId + ? parsed.sessionId + : fallbackSessionId; + const branchName = + typeof parsed.gitBranch === "string" && parsed.gitBranch + ? parsed.gitBranch + : null; + const events: PrDraft[] = []; + for (const block of content) { + if (!isRecord(block) || block.type !== "tool_result") continue; + const toolUseId = + typeof block.tool_use_id === "string" ? block.tool_use_id : null; + const command = toolUseId + ? state.claudeBashCommands.get(toolUseId) + : undefined; + if (toolUseId) state.claudeBashCommands.delete(toolUseId); + if (!isPrCreateCommand(command)) continue; + const body = flattenContent(block.content); + for (const ref of extractPrUrlsFromText(body)) { + events.push({ + prUrl: ref.prUrl, + prNumber: ref.prNumber, + repoFullName: ref.repoFullName, + branchName, + headSha: null, + harness: "claude-code", + externalSessionId: sessionId, + }); + } + } + return events; +} + +function codexEventsFor( + body: string, + command: string, + sessionId: string, +): PrDraft[] { + const branchName = extractHeadBranch(command); + return extractPrUrlsFromText(body).map((ref) => ({ + prUrl: ref.prUrl, + prNumber: ref.prNumber, + repoFullName: ref.repoFullName, + branchName, + headSha: null, + harness: "codex", + externalSessionId: sessionId, + })); +} + +function codexEvents( + parsed: Record, + fallbackSessionId: string, + state: SessionParserState, +): PrDraft[] { + const payload = isRecord(parsed.payload) ? parsed.payload : null; + if (!payload) return []; + const sessionId = state.codexSessionId || fallbackSessionId; + + switch (payload.type) { + case "function_call": { + const callId = + typeof payload.call_id === "string" ? payload.call_id : null; + const command = extractCodexCommand(payload.arguments); + if (callId) rememberCommand(state.codexCallCommands, callId, command); + return []; + } + case "function_call_output": { + const callId = + typeof payload.call_id === "string" ? payload.call_id : null; + const command = callId + ? state.codexCallCommands.get(callId) + : undefined; + if (callId) state.codexCallCommands.delete(callId); + if (!isPrCreateCommand(command)) return []; + return codexEventsFor( + flattenContent(payload.output), + command || "", + sessionId, + ); + } + case "exec_command_end": { + const command = extractParsedCmd(payload.parsed_cmd); + if (!isPrCreateCommand(command)) return []; + return codexEventsFor( + flattenContent(payload.aggregated_output), + command, + sessionId, + ); + } + default: + return []; + } +} + +export function parseSessionLine( + parsed: Record, + fallbackSessionId: string, + state: SessionParserState, +): PrDraft[] { + if (!isRecord(parsed)) return []; + switch (parsed.type) { + case "pr-link": + return loopEvent(parsed, fallbackSessionId); + case "assistant": + case "user": + return claudeEvents(parsed, fallbackSessionId, state); + case "event_msg": + case "response_item": + return codexEvents(parsed, fallbackSessionId, state); + case "session_meta": { + const payload = isRecord(parsed.payload) ? parsed.payload : null; + if (payload && typeof payload.id === "string") { + state.codexSessionId = payload.id; + } + return []; + } + default: + return []; + } +} + +// --------------------------------------------------------------------------- +// Extraction: from pre-read JSONL text (from pr-extractor.js) +// --------------------------------------------------------------------------- + +export function extractPullRequestsFromText( + text: string, + sessionId: string | null, +): PrDraft[] { + if (typeof text !== "string" || text.length === 0) return []; + const canonicalSessionId = + typeof sessionId === "string" ? sessionId : null; + const state = createSessionParserState(); + const observedAt = new Date().toISOString(); + const out: PrDraft[] = []; + + for (const line of text.split("\n")) { + if (!line) continue; + const parsed = safeParseLine(line); + if (!parsed) continue; + for (const ev of parseSessionLine( + parsed, + canonicalSessionId || "", + state, + )) { + out.push({ + prUrl: ev.prUrl, + prNumber: ev.prNumber, + repoFullName: ev.repoFullName, + branchName: ev.branchName, + headSha: ev.headSha, + harness: ev.harness, + externalSessionId: canonicalSessionId || ev.externalSessionId, + observedAt, + }); + } + } + return out; +} + +/** + * Session-shaped entry used by the live importSession PR-extract block. + * Reads the file from disk and delegates to extractPullRequestsFromText. + * Returns [] on read failure for backward compat. + */ +export function extractPullRequestsFromSession(session: { + sessionId?: string; + sourceLogPath?: string; +}): PrDraft[] { + if (!session || typeof session.sourceLogPath !== "string") return []; + let text: string; + try { + text = readFileSync(session.sourceLogPath, "utf8"); + } catch { + return []; + } + return extractPullRequestsFromText(text, session.sessionId ?? null); +} + +// --------------------------------------------------------------------------- +// Extraction: from live hook event data +// --------------------------------------------------------------------------- + +interface HookEventData { + tool_name?: string; + tool_input?: Record | null; + tool_result?: unknown; + session_id?: string; + transcript_path?: string; + git_branch?: string; +} + +export function extractPrFromEvent( + data: HookEventData | null | undefined, + harness: string, + sessionId: string | null, +): PrDraft | null { + if (!data || typeof data !== "object") return null; + const _toolName = data.tool_name; + const input = + data.tool_input && typeof data.tool_input === "object" + ? data.tool_input + : null; + + if (!isPrCreateCommand(input?.command)) return null; + + // Extract PR URL from tool result + const resultText = flattenContent(data.tool_result); + const refs = extractPrUrlsFromText(resultText); + if (refs.length === 0) return null; + + const ref = refs[0]; + const branchName = + typeof data.git_branch === "string" && data.git_branch + ? data.git_branch + : extractHeadBranch( + typeof input?.command === "string" ? (input.command as string) : "", + ); + + return { + prUrl: ref.prUrl, + prNumber: ref.prNumber, + repoFullName: ref.repoFullName, + branchName, + headSha: null, + harness, + externalSessionId: sessionId || data.session_id || "", + observedAt: nowIso(), + }; +} + +// --------------------------------------------------------------------------- +// DB: upsertPullRequest +// --------------------------------------------------------------------------- + +interface PullRequestInput { + externalSessionId: string; + prUrl: string; + prNumber: number; + repoFullName: string; + branchName?: string | null; + headSha?: string | null; + title?: string | null; + harness: string; + observedAt?: string; +} + +export async function upsertPullRequest( + db: DbClient, + pr: PullRequestInput, +): Promise<{ id: string; created: boolean }> { + const id = pullRequestId(pr.harness, pr.externalSessionId, pr.prUrl); + const existingResult = await db.query<{ id: string } & Record>( + `SELECT id FROM pull_requests WHERE id = $1`, + [id], + ); + + if (existingResult.rows.length > 0) { + await db.query( + `UPDATE pull_requests + SET branch_name = COALESCE(branch_name, $1), + head_sha = COALESCE(head_sha, $2), + title = COALESCE(title, $3) + WHERE id = $4`, + [pr.branchName || null, pr.headSha || null, pr.title || null, id], + ); + return { id, created: false }; + } + + await db.query( + `INSERT INTO pull_requests + (id, session_id, pr_url, pr_number, repo_full_name, branch_name, + head_sha, title, harness, observed_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + pr.externalSessionId || null, + pr.prUrl, + pr.prNumber, + pr.repoFullName, + pr.branchName || null, + pr.headSha || null, + pr.title || null, + pr.harness, + pr.observedAt || nowIso(), + nowIso(), + ], + ); + return { id, created: true }; +} + +// --------------------------------------------------------------------------- +// DB: list / count / stats +// --------------------------------------------------------------------------- + +interface PrListFilters { + sessionId?: string | null; + repo?: string | null; + limit?: number; + offset?: number; +} + +function buildPrFilter(opts: PrListFilters): { + where: string; + params: unknown[]; + nextParam: number; +} { + const clauses: string[] = []; + const params: unknown[] = []; + let idx = 1; + if (opts.sessionId) { + clauses.push(`session_id = $${idx}`); + params.push(opts.sessionId); + idx++; + } + if (opts.repo) { + clauses.push(`repo_full_name = $${idx}`); + params.push(opts.repo); + idx++; + } + return { + where: clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "", + params, + nextParam: idx, + }; +} + +export async function listPullRequests( + db: DbClient, + opts: PrListFilters = {}, +): Promise[]> { + const { limit = 100, offset = 0 } = opts; + const { where, params, nextParam } = buildPrFilter(opts); + const result = await db.query( + `SELECT * FROM pull_requests${where} + ORDER BY observed_at DESC LIMIT $${nextParam} OFFSET $${nextParam + 1}`, + [...params, limit, offset], + ); + return result.rows; +} + +export async function countPullRequests( + db: DbClient, + opts: Omit = {}, +): Promise { + const { where, params } = buildPrFilter(opts); + const result = await db.query<{ c: number } & Record>( + `SELECT COUNT(*)::int AS c FROM pull_requests${where}`, + params, + ); + return result.rows[0]?.c ?? 0; +} + +export async function countRepos(db: DbClient): Promise { + const result = await db.query<{ c: number } & Record>( + `SELECT COUNT(DISTINCT repo_full_name)::int AS c FROM pull_requests`, + ); + return result.rows[0]?.c ?? 0; +} + +export interface PrStats { + totalPrs: number; + totalRepos: number; + totalSessions: number; +} + +export async function getPrStats(db: DbClient): Promise { + const result = await db.query<{ + total_prs: number; + total_repos: number; + total_sessions: number; + } & Record>( + `SELECT + COUNT(*)::int AS total_prs, + COUNT(DISTINCT repo_full_name)::int AS total_repos, + COUNT(DISTINCT session_id)::int AS total_sessions + FROM pull_requests`, + ); + const row = result.rows[0]; + return { + totalPrs: row?.total_prs ?? 0, + totalRepos: row?.total_repos ?? 0, + totalSessions: row?.total_sessions ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// DB: session-grouped PR listing +// --------------------------------------------------------------------------- + +interface SessionWithPrs extends Record { + session_id: string | null; + session_name: string | null; + session_started_at: string | null; + session_cwd: string | null; + pr_count: number; + last_pr_at: string | null; + harness: string | null; + pull_requests: Record[]; +} + +export async function listPrSessions( + db: DbClient, + opts: { limit?: number; offset?: number } = {}, +): Promise { + const { limit = 100, offset = 0 } = opts; + const result = await db.query<{ + session_id: string | null; + session_name: string | null; + session_started_at: string | null; + session_cwd: string | null; + pr_count: number; + last_pr_at: string | null; + harness: string | null; + } & Record>( + `SELECT + pr.session_id AS session_id, + s.name AS session_name, + s.started_at AS session_started_at, + s.cwd AS session_cwd, + COUNT(*)::int AS pr_count, + MAX(pr.observed_at) AS last_pr_at, + MIN(pr.harness) AS harness + FROM pull_requests pr + LEFT JOIN sessions s ON s.id = pr.session_id + GROUP BY pr.session_id, s.name, s.started_at, s.cwd + ORDER BY last_pr_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + + const rows: SessionWithPrs[] = []; + for (const row of result.rows) { + const prsResult = await db.query( + `SELECT id, pr_url, pr_number, repo_full_name, branch_name, head_sha, + title, harness, observed_at + FROM pull_requests WHERE session_id IS NOT DISTINCT FROM $1 + ORDER BY observed_at DESC`, + [row.session_id], + ); + rows.push({ + ...row, + pull_requests: prsResult.rows, + }); + } + return rows; +} + +export async function countSessionsWithPullRequests( + db: DbClient, +): Promise { + const result = await db.query<{ c: number } & Record>( + `SELECT COUNT(*)::int AS c FROM (SELECT 1 FROM pull_requests GROUP BY session_id) sub`, + ); + return result.rows[0]?.c ?? 0; +} + +export async function sessionIdsWithPullRequests( + db: DbClient, +): Promise<{ session_id: string; c: number }[]> { + const result = await db.query< + { session_id: string; c: number } & Record + >( + `SELECT session_id, COUNT(*)::int AS c FROM pull_requests + WHERE session_id IS NOT NULL GROUP BY session_id`, + ); + return result.rows; +} + +// --------------------------------------------------------------------------- +// DB: backfill mtime cache +// --------------------------------------------------------------------------- + +export async function markBackfillSeen( + db: DbClient, + sessionId: string, + filePath: string, + mtime: number, +): Promise { + await db.query( + `INSERT INTO pr_backfill_seen (session_id, file_path, file_mtime_ms, scanned_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(session_id) DO UPDATE SET + file_path = EXCLUDED.file_path, + file_mtime_ms = EXCLUDED.file_mtime_ms, + scanned_at = EXCLUDED.scanned_at`, + [sessionId, filePath, mtime, nowIso()], + ); +} + +async function getBackfillSeen( + db: DbClient, + sessionId: string, +): Promise<{ file_mtime_ms: number } | null> { + const result = await db.query< + { file_mtime_ms: number } & Record + >( + `SELECT file_mtime_ms FROM pr_backfill_seen WHERE session_id = $1`, + [sessionId], + ); + return result.rows[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Backfill: scan Claude session transcripts for PR artifacts +// --------------------------------------------------------------------------- + +function resolveClaudeProjectsDir(): string { + return ( + process.env.CLAUDE_PROJECTS_DIR || + join( + process.env.CLAUDE_HOME || join(homedir(), ".claude"), + "projects", + ) + ); +} + +interface SessionRow { + id: string; + sourceLogPath?: string; +} + +export async function backfillPrsFromTranscripts( + db: DbClient, + sessionRows?: SessionRow[], + options?: { projectsDir?: string }, +): Promise<{ + captured: number; + deduped: number; + scanned: number; + skipped: number; + errors: number; +}> { + const projectsDir = options?.projectsDir ?? resolveClaudeProjectsDir(); + let captured = 0; + let deduped = 0; + let scanned = 0; + let skipped = 0; + let errors = 0; + + // If sessionRows are provided, scan those specific sessions. + if (sessionRows && sessionRows.length > 0) { + for (const session of sessionRows) { + if (!session.sourceLogPath) continue; + const filePath = session.sourceLogPath; + const sessionId = session.id; + + let stat: ReturnType; + try { + stat = statSync(filePath); + } catch { + continue; + } + const mtimeMs = stat.mtimeMs; + + const seen = await getBackfillSeen(db, sessionId); + if (seen && seen.file_mtime_ms === mtimeMs) { + skipped += 1; + continue; + } + + scanned += 1; + + let text: string; + try { + text = readFileSync(filePath, "utf8"); + } catch { + errors += 1; + continue; + } + + let fileSucceeded = true; + try { + for (const draft of extractPullRequestsFromText(text, sessionId)) { + try { + const r = await upsertPullRequest(db, draft); + if (r.created) captured += 1; + else deduped += 1; + } catch { + fileSucceeded = false; + errors += 1; + } + } + } catch { + fileSucceeded = false; + errors += 1; + } + + if (fileSucceeded) { + try { + await markBackfillSeen(db, sessionId, filePath, mtimeMs); + } catch { + errors += 1; + } + } + } + return { captured, deduped, scanned, skipped, errors }; + } + + // Fallback: walk the Claude projects directory + let projectDirs: string[]; + try { + projectDirs = readdirSync(projectsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch (e: unknown) { + if (e && typeof e === "object" && (e as NodeJS.ErrnoException).code !== "ENOENT") { + return { captured, deduped, scanned, skipped, errors: 1 }; + } + return { captured, deduped, scanned, skipped, errors }; + } + + for (const projDir of projectDirs) { + const projPath = join(projectsDir, projDir); + let files: string[]; + try { + files = readdirSync(projPath).filter((f) => f.endsWith(".jsonl")); + } catch (e: unknown) { + if ( + e && + typeof e === "object" && + (e as NodeJS.ErrnoException).code !== "ENOENT" + ) { + errors += 1; + } + continue; + } + for (const file of files) { + const filePath = join(projPath, file); + const sessionId = basename(file, ".jsonl"); + + let stat: ReturnType; + try { + stat = statSync(filePath); + } catch (e: unknown) { + if ( + e && + typeof e === "object" && + (e as NodeJS.ErrnoException).code !== "ENOENT" + ) { + errors += 1; + } + continue; + } + const mtimeMs = stat.mtimeMs; + + const seen = await getBackfillSeen(db, sessionId); + if (seen && seen.file_mtime_ms === mtimeMs) { + skipped += 1; + continue; + } + + scanned += 1; + + let text: string; + try { + text = readFileSync(filePath, "utf8"); + } catch { + errors += 1; + continue; + } + + let fileSucceeded = true; + try { + for (const draft of extractPullRequestsFromText(text, sessionId)) { + try { + const r = await upsertPullRequest(db, draft); + if (r.created) captured += 1; + else deduped += 1; + } catch { + fileSucceeded = false; + errors += 1; + } + } + } catch { + fileSucceeded = false; + errors += 1; + } + + if (fileSucceeded) { + try { + await markBackfillSeen(db, sessionId, filePath, mtimeMs); + } catch { + errors += 1; + } + } + } + } + + return { captured, deduped, scanned, skipped, errors }; +} + +// --------------------------------------------------------------------------- +// Utility re-export +// --------------------------------------------------------------------------- + +export function clampInt( + raw: unknown, + fallback: number, + min: number, + max: number, +): number { + const n = parseInt(String(raw), 10); + if (Number.isNaN(n)) return fallback; + return Math.min(Math.max(n, min), max); +} diff --git a/apps/desktop/src/renderer/components/features/CatalogCard.tsx b/apps/desktop/src/renderer/components/features/CatalogCard.tsx new file mode 100644 index 00000000..85b9674a --- /dev/null +++ b/apps/desktop/src/renderer/components/features/CatalogCard.tsx @@ -0,0 +1,138 @@ +import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; +import { Button } from "@closedloop-ai/design-system/components/ui/button"; +import { Download, ExternalLink, GitFork, Star, Trash2 } from "lucide-react"; +import type { CatalogEntry } from "../../../shared/agent-db-contract"; +import { DashboardCard, cx } from "../layout/page-shell"; +import { Sparkline } from "./Sparkline"; + +export interface CatalogCardProps { + entry: CatalogEntry; + onInstall: (packId: string, harness: string) => void; + onUninstall: (packId: string, harness: string) => void; + onClick: (packId: string) => void; + installing?: Record; +} + +export function CatalogCard({ + entry, + onInstall, + onUninstall, + onClick, + installing, +}: CatalogCardProps) { + const isInstalled = entry.installedHarnesses.length > 0; + const starHistory = entry.history?.map((h) => h.stars) ?? []; + + return ( + +
onClick(entry.packId)} className="space-y-3"> + {/* Header row */} +
+
+

{entry.displayName}

+ {entry.category && ( + {entry.category} + )} +
+ {isInstalled ? ( + Installed + ) : ( + Available + )} +
+ + {/* Description */} + {entry.description && ( +

+ {entry.description} +

+ )} + + {/* Stats row */} +
+ {entry.stars != null && ( + + + {formatCount(entry.stars)} + + )} + {entry.forks != null && ( + + + {formatCount(entry.forks)} + + )} + {starHistory.length >= 2 && } + {entry.githubUrl && ( + e.stopPropagation()} + className="ml-auto hover:text-[var(--foreground)]" + title="View on GitHub" + > + + + )} +
+ + {/* Harness badges */} +
+ {entry.harnesses.map((h) => ( + {h} + ))} +
+
+ + {/* Per-harness install/uninstall buttons */} +
e.stopPropagation()} + > + {entry.harnesses.map((harness) => { + const installed = entry.installedHarnesses.includes(harness); + const busy = installing?.[`${entry.packId}:${harness}`] ?? false; + + return installed ? ( + + ) : ( + + ); + })} +
+
+ ); +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} diff --git a/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx b/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx index 3533bda3..b8e1fca0 100644 --- a/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx +++ b/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx @@ -11,328 +11,221 @@ import { } from "@closedloop-ai/design-system/components/ui/table"; import { Bot, - ClipboardList, - GitPullRequest, Package, Sparkles, Wrench, } from "lucide-react"; -import { useMemo } from "react"; import type { ReactNode } from "react"; import type { - DashboardPackSummary, - DashboardPlanSummary, - DashboardPullRequestSummary, - DashboardSkillSummary, DashboardSubAgentSummary, DashboardToolSummary, + SkillWithInvocations, } from "../../../shared/agent-db-contract"; import { useQueryCache } from "../../hooks/useQueryCache"; import { DASHBOARD_METRIC_CARD_CLASS_NAME, DASHBOARD_TABLE_CLASS_NAME, - DASHBOARD_WIDE_GRID_CLASS_NAME, DashboardCard, LoadingState, PageShell, cx, } from "../layout/page-shell"; +import { PacksCatalog } from "./PacksCatalog"; +import { PlansView as PlansViewFull } from "./PlansView"; +import { PullRequestsView as PullRequestsViewFull } from "./PullRequestsView"; -type FeatureKind = "packs" | "skills" | "tools" | "subagents" | "plans" | "pull-requests"; - -const FEATURE_LABELS: Record = { - packs: { - title: "Packs", - description: "Pack activity inferred from imported skill usage", - }, - skills: { - title: "Skills", - description: "Skill invocations captured from agent sessions", - }, - tools: { - title: "Tools", - description: "Tool calls grouped across all imported sessions", - }, - subagents: { - title: "SubAgents", - description: "Subagent roles, outcomes, and session coverage", - }, - plans: { - title: "Plans", - description: "Plans extracted from imported transcripts", - }, - "pull-requests": { - title: "Pull Requests", - description: "Pull request artifacts associated with agent sessions", - }, -}; +// ---- Full-featured views (delegate to dedicated components) ---- export function PacksView() { - return ; -} - -export function SkillsView() { - return ; -} - -export function ToolsView() { - return ; -} - -export function SubAgentsView() { - return ; + return ; } export function PlansView() { - return ; + return ; } export function PullRequestsView() { - return ; + return ; } -function FeatureView({ kind }: { kind: FeatureKind }) { - const labels = FEATURE_LABELS[kind]; - const { data: packs, loading: packsLoading } = useQueryCache( - "db:packs", - () => window.desktopApi.db.getPacks(), +// ---- Stub views kept for Skills, Tools, SubAgents ---- + +export function SkillsView() { + const { data: skills, loading } = useQueryCache( + "db:all-skills", + () => window.desktopApi.db.getAllSkills(), 5_000, 10_000, ); - const { data: skills, loading: skillsLoading } = useQueryCache( - "db:skills", - () => window.desktopApi.db.getSkills(), - 5_000, - 10_000, + + if (loading && !skills) { + return ; + } + + const rows = skills ?? []; + + return ( + +
+ + sum + r.invocationCount, 0)} + icon={Sparkles} + /> + r.packId).filter(Boolean)).size} + icon={Package} + /> +
+ + + + + +
Name
+
Pack
+
Harness
+
Calls
+
Last Used
+
+
+ {rows.map((row) => ( + + {row.name} + {row.packId ?? "-"} + {row.harness ? {row.harness} : "-"} + {row.invocationCount} + {formatDate(row.lastUsedAt)} + + ))} +
+
+
); - const { data: tools, loading: toolsLoading } = useQueryCache( +} + +export function ToolsView() { + const { data: tools, loading } = useQueryCache( "db:tools", () => window.desktopApi.db.getTools(), 5_000, 10_000, ); - const { data: subagents, loading: subagentsLoading } = useQueryCache( - "db:subagents", - () => window.desktopApi.db.getSubAgents(), - 5_000, - 10_000, - ); - const { data: plans, loading: plansLoading } = useQueryCache( - "db:plans", - () => window.desktopApi.db.getPlans(), - 5_000, - 10_000, - ); - const { data: pullRequests, loading: pullRequestsLoading } = useQueryCache( - "db:pull-requests", - () => window.desktopApi.db.getPullRequests(), - 5_000, - 10_000, - ); - const loading = { - packs: packsLoading, - skills: skillsLoading, - tools: toolsLoading, - subagents: subagentsLoading, - plans: plansLoading, - "pull-requests": pullRequestsLoading, - }[kind]; - - const stats = useMemo(() => ({ - packCount: packs?.length ?? 0, - skillCount: skills?.length ?? 0, - toolCount: tools?.length ?? 0, - subagentCount: subagents?.length ?? 0, - planCount: plans?.length ?? 0, - pullRequestCount: pullRequests?.length ?? 0, - }), [packs, skills, tools, subagents, plans, pullRequests]); - - if (loading) { - return ; + if (loading && !tools) { + return ; } - return ( - + const rows = tools ?? []; -
- - - - - - + return ( + +
+ + sum + r.invocationCount, 0)} + icon={Wrench} + /> + sum + r.sessionCount, 0)} + icon={Wrench} + />
- {kind === "packs" && } - {kind === "skills" && } - {kind === "tools" && } - {kind === "subagents" && } - {kind === "plans" && } - {kind === "pull-requests" && } + + + + +
Tool
+
Calls
+
Sessions
+
Last Used
+
+
+ {rows.map((row) => ( + + {row.toolName} + {row.invocationCount} + {row.sessionCount} + {formatDate(row.lastUsedAt)} + + ))} +
+
); } -function PacksTable({ rows }: { rows: DashboardPackSummary[] }) { - return ( - - - - -
Name
-
Harness
-
Skills
-
Calls
-
Last Used
-
-
- {rows.map((row) => ( - - {row.name} - {row.harness} - {row.skillCount} - {row.toolCallCount} - {formatDate(row.lastUsedAt)} - - ))} -
-
- ); -} - -function SkillsTable({ rows }: { rows: DashboardSkillSummary[] }) { - return ( - - - - -
Name
-
Pack
-
Harness
-
Calls
-
Last Used
-
-
- {rows.map((row) => ( - - {row.name} - {row.packId ?? "-"} - {row.harness} - {row.invocationCount} - {formatDate(row.lastUsedAt)} - - ))} -
-
+export function SubAgentsView() { + const { data: subagents, loading } = useQueryCache( + "db:subagents", + () => window.desktopApi.db.getSubAgents(), + 5_000, + 10_000, ); -} -function ToolsTable({ rows }: { rows: DashboardToolSummary[] }) { - return ( - - - - -
Tool
-
Calls
-
Sessions
-
Last Used
-
-
- {rows.map((row) => ( - - {row.toolName} - {row.invocationCount} - {row.sessionCount} - {formatDate(row.lastUsedAt)} - - ))} -
-
- ); -} + if (loading && !subagents) { + return ; + } -function SubAgentsTable({ rows }: { rows: DashboardSubAgentSummary[] }) { - return ( - - - - -
Role
-
Total
-
Completed
-
Errors
-
Sessions
-
Last Used
-
-
- {rows.map((row) => ( - - {row.subagentType} - {row.total} - {row.completed} - {row.errors} - {row.sessions} - {formatDate(row.lastUsedAt)} - - ))} -
-
- ); -} + const rows = subagents ?? []; -function PlansTable({ rows }: { rows: DashboardPlanSummary[] }) { return ( - -
- {rows.map((row) => ( -
-
-

{row.title}

- {formatDate(row.timestamp)} -
-
- {row.harness && {row.harness}} - {row.source && {row.source}} - {row.cwd && {row.cwd}} -
-
- ))} + +
+ + sum + r.total, 0)} + icon={Bot} + /> + sum + r.sessions, 0)} + icon={Bot} + />
- - ); -} -function PullRequestsTable({ rows }: { rows: DashboardPullRequestSummary[] }) { - return ( - - - - -
Pull Request
-
Repo
-
Harness
-
Observed
-
-
- {rows.map((row) => ( - - - - #{row.prNumber}{row.title ? ` ${row.title}` : ""} - - - {row.repoFullName} - {row.harness ? {row.harness} : "-"} - {formatDate(row.observedAt)} - - ))} -
-
+ + + + +
Role
+
Total
+
Completed
+
Errors
+
Sessions
+
Last Used
+
+
+ {rows.map((row) => ( + + {row.subagentType} + {row.total} + {row.completed} + {row.errors} + {row.sessions} + {formatDate(row.lastUsedAt)} + + ))} +
+
+
); } +// ---- Shared primitives (kept for the stub views) ---- + function FeatureCard({ title, empty, diff --git a/apps/desktop/src/renderer/components/features/InstallModal.tsx b/apps/desktop/src/renderer/components/features/InstallModal.tsx new file mode 100644 index 00000000..20665b55 --- /dev/null +++ b/apps/desktop/src/renderer/components/features/InstallModal.tsx @@ -0,0 +1,168 @@ +import { Button } from "@closedloop-ai/design-system/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@closedloop-ai/design-system/components/ui/dialog"; +import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { InstallRunRecord } from "../../../shared/agent-db-contract"; +import { cx } from "../layout/page-shell"; + +export interface InstallModalProps { + open: boolean; + onClose: () => void; + packId: string; + harness: string; + action: "install" | "uninstall"; + runId: number | null; + /** Command that was or will be executed (for display only). */ + command?: string | null; +} + +export function InstallModal({ + open, + onClose, + packId, + harness, + action, + runId, + command, +}: InstallModalProps) { + const [lines, setLines] = useState>([]); + const [exitCode, setExitCode] = useState(null); + const [done, setDone] = useState(false); + const scrollRef = useRef(null); + + // Auto-scroll to bottom on new output + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines]); + + // Subscribe to streamed install output via IPC + useEffect(() => { + if (!open || runId == null) return; + setLines([]); + setExitCode(null); + setDone(false); + + const unsubscribe = window.desktopApi.onInstallOutput?.((payload) => { + if (payload.runId !== runId) return; + + if (payload.type === "exit") { + const code = parseInt(payload.data, 10); + setExitCode(Number.isNaN(code) ? null : code); + setDone(true); + return; + } + + setLines((prev) => [...prev, { type: payload.type, data: payload.data }]); + }); + + return () => { + unsubscribe?.(); + }; + }, [open, runId]); + + // Fallback: poll install runs if the IPC stream is not wired yet + useEffect(() => { + if (!open || runId == null || done) return; + // Only poll if onInstallOutput is not available + if (window.desktopApi.onInstallOutput) return; + + const interval = setInterval(async () => { + try { + const runs: InstallRunRecord[] = await window.desktopApi.db.getInstallRuns(packId); + const run = runs.find((r) => r.id === runId); + if (run?.endedAt) { + setExitCode(run.exitCode); + if (run.stdoutTail) { + setLines([{ type: "stdout", data: run.stdoutTail }]); + } + if (run.stderrTail) { + setLines((prev) => [...prev, { type: "stderr", data: run.stderrTail! }]); + } + setDone(true); + } + } catch { + // ignore poll errors + } + }, 1_500); + + return () => clearInterval(interval); + }, [open, runId, packId, done]); + + const handleClose = useCallback(() => { + setLines([]); + setExitCode(null); + setDone(false); + onClose(); + }, [onClose]); + + const title = action === "install" + ? `Installing ${packId} (${harness})` + : `Uninstalling ${packId} (${harness})`; + + const success = done && (exitCode === 0 || exitCode == null); + const failed = done && exitCode != null && exitCode !== 0; + + return ( + !v && handleClose()}> + + + + {!done && } + {success && } + {failed && } + {title} + + + {done + ? success + ? `${action === "install" ? "Installation" : "Uninstallation"} completed successfully.` + : `Process exited with code ${exitCode}.` + : `Running ${action}...`} + + + + {/* Command preview */} + {command && ( +
+ $ {command} +
+ )} + + {/* Scrolling output */} +
+ {lines.length === 0 && !done && ( + Waiting for output... + )} + {lines.map((line, i) => ( +
+ {line.data} +
+ ))} +
+ + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx new file mode 100644 index 00000000..afff5acf --- /dev/null +++ b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx @@ -0,0 +1,574 @@ +import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; +import { Button } from "@closedloop-ai/design-system/components/ui/button"; +import { EmptyState } from "@closedloop-ai/design-system/components/ui/empty-state"; +import { Input } from "@closedloop-ai/design-system/components/ui/input"; +import { + Table as DsTable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@closedloop-ai/design-system/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@closedloop-ai/design-system/components/ui/tabs"; +import { + ArrowLeft, + ExternalLink, + Package, + RefreshCw, + Search, + Star, + GitFork, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import type { + CatalogEntry, + InstalledPack, + InstalledPackDetail, +} from "../../../shared/agent-db-contract"; +import { useQueryCache, invalidateCache } from "../../hooks/useQueryCache"; +import { + DASHBOARD_TABLE_CLASS_NAME, + DashboardCard, + LoadingState, + PageShell, + cx, +} from "../layout/page-shell"; +import { CatalogCard } from "./CatalogCard"; +import { InstallModal } from "./InstallModal"; +import { Sparkline } from "./Sparkline"; + +type ViewMode = "catalog" | "detail"; + +interface InstallState { + open: boolean; + packId: string; + harness: string; + action: "install" | "uninstall"; + runId: number | null; + command: string | null; +} + +const EMPTY_INSTALL: InstallState = { + open: false, + packId: "", + harness: "", + action: "install", + runId: null, + command: null, +}; + +export function PacksCatalog() { + const [search, setSearch] = useState(""); + const [viewMode, setViewMode] = useState("catalog"); + const [selectedPackId, setSelectedPackId] = useState(null); + const [installing, setInstalling] = useState>({}); + const [installModal, setInstallModal] = useState(EMPTY_INSTALL); + const [refreshing, setRefreshing] = useState(false); + + // -- Data fetching -- + + const { data: catalog, loading: catalogLoading } = useQueryCache( + "db:catalog", + () => window.desktopApi.db.getCatalog(), + 5_000, + 10_000, + ); + + const { data: installedPacks, loading: installedLoading } = useQueryCache( + "db:installed-packs", + () => window.desktopApi.db.getInstalledPacks(), + 5_000, + 10_000, + ); + + const { data: packDetail } = useQueryCache( + `db:pack-detail:${selectedPackId}`, + () => (selectedPackId ? window.desktopApi.db.getPackDetail(selectedPackId) : Promise.resolve(null)), + 5_000, + 10_000, + ); + + // -- Filtering -- + + const lowerSearch = search.toLowerCase(); + + const filteredCatalog = useMemo(() => { + if (!catalog) return []; + if (!lowerSearch) return catalog; + return catalog.filter( + (e) => + e.displayName.toLowerCase().includes(lowerSearch) || + e.description?.toLowerCase().includes(lowerSearch) || + e.category?.toLowerCase().includes(lowerSearch) || + e.packId.toLowerCase().includes(lowerSearch), + ); + }, [catalog, lowerSearch]); + + const installedEntries = useMemo( + () => filteredCatalog.filter((e) => e.installedHarnesses.length > 0), + [filteredCatalog], + ); + + const discoverEntries = useMemo( + () => filteredCatalog.filter((e) => e.installedHarnesses.length === 0), + [filteredCatalog], + ); + + // -- Actions -- + + const handleInstall = useCallback(async (packId: string, harness: string) => { + const key = `${packId}:${harness}`; + setInstalling((prev) => ({ ...prev, [key]: true })); + + try { + const entry = catalog?.find((e) => e.packId === packId); + const command = entry?.installCommands?.[harness] ?? null; + const { runId } = await window.desktopApi.db.catalogInstall(packId, harness); + setInstallModal({ open: true, packId, harness, action: "install", runId, command }); + } catch (err) { + console.error("Install failed:", err); + } finally { + setInstalling((prev) => ({ ...prev, [key]: false })); + } + }, [catalog]); + + const handleUninstall = useCallback(async (packId: string, harness: string) => { + const key = `${packId}:${harness}`; + setInstalling((prev) => ({ ...prev, [key]: true })); + + try { + const entry = catalog?.find((e) => e.packId === packId); + const command = entry?.uninstallCommands?.[harness] ?? null; + const { runId } = await window.desktopApi.db.catalogUninstall(packId, harness); + setInstallModal({ open: true, packId, harness, action: "uninstall", runId, command }); + } catch (err) { + console.error("Uninstall failed:", err); + } finally { + setInstalling((prev) => ({ ...prev, [key]: false })); + } + }, [catalog]); + + const handleCloseModal = useCallback(() => { + setInstallModal(EMPTY_INSTALL); + // Refresh catalog and installed packs after install/uninstall + invalidateCache("db:catalog"); + invalidateCache("db:installed-packs"); + }, []); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + await window.desktopApi.db.catalogRefresh(); + invalidateCache("db:catalog"); + } finally { + setRefreshing(false); + } + }, []); + + const handleCardClick = useCallback((packId: string) => { + setSelectedPackId(packId); + setViewMode("detail"); + }, []); + + const handleBack = useCallback(() => { + setViewMode("catalog"); + setSelectedPackId(null); + }, []); + + // -- Loading state -- + + if (catalogLoading && !catalog) { + return ; + } + + // -- Detail view -- + + if (viewMode === "detail" && selectedPackId) { + const catalogEntry = catalog?.find((e) => e.packId === selectedPackId); + return ( + + ); + } + + // -- Catalog view -- + + return ( + + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ + + + + All ({filteredCatalog.length}) + + + Installed ({installedEntries.length}) + + + Discover ({discoverEntries.length}) + + + + + + + + + {installedEntries.length === 0 ? ( + + ) : ( + + )} + + + + {discoverEntries.length === 0 ? ( + + ) : ( + + )} + + + + {/* Installed packs (from local detection) */} + {!installedLoading && installedPacks && installedPacks.length > 0 && ( + +
+ + + + Pack ID + Harnesses + Skills + Last Seen + + + + {installedPacks.map((pack) => ( + handleCardClick(pack.packId)} + > + {pack.packId} + +
+ {pack.harnesses.map((h) => ( + {h} + ))} +
+
+ {pack.skillCount} + {formatDate(pack.lastSeenAt)} +
+ ))} +
+
+
+
+ )} + + {/* Install modal */} + +
+ ); +} + +// ---- Grid of catalog cards ---- + +function CatalogGrid({ + entries, + onInstall, + onUninstall, + onClick, + installing, +}: { + entries: CatalogEntry[]; + onInstall: (packId: string, harness: string) => void; + onUninstall: (packId: string, harness: string) => void; + onClick: (packId: string) => void; + installing: Record; +}) { + if (entries.length === 0) { + return ; + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} + +// ---- Pack detail view ---- + +function PackDetailView({ + packId, + catalogEntry, + packDetail, + onBack, + onInstall, + onUninstall, + installing, +}: { + packId: string; + catalogEntry: CatalogEntry | null; + packDetail: InstalledPackDetail | null; + onBack: () => void; + onInstall: (packId: string, harness: string) => void; + onUninstall: (packId: string, harness: string) => void; + installing: Record; +}) { + const { data: readme } = useQueryCache( + `db:catalog-readme:${packId}`, + () => window.desktopApi.db.getCatalogReadme(packId), + 30_000, + 60_000, + ); + + const displayName = catalogEntry?.displayName ?? packId; + const description = catalogEntry?.descriptionLive ?? catalogEntry?.description; + const starHistory = catalogEntry?.history?.map((h) => h.stars) ?? []; + + return ( + + {/* Back button */} +
+ +
+ + {/* Stats row */} + {catalogEntry && ( +
+ {catalogEntry.stars != null && ( + + + {catalogEntry.stars.toLocaleString()} stars + + )} + {catalogEntry.forks != null && ( + + + {catalogEntry.forks.toLocaleString()} forks + + )} + {starHistory.length >= 2 && } + {catalogEntry.githubUrl && ( + + GitHub + + )} + {catalogEntry.verified && Verified} + {catalogEntry.category && {catalogEntry.category}} +
+ )} + + {/* Harnesses + install actions */} + {catalogEntry && ( + +
+ {catalogEntry.harnesses.map((harness) => { + const installed = catalogEntry.installedHarnesses.includes(harness); + const busy = installing[`${packId}:${harness}`] ?? false; + + return ( +
+ {harness} + {installed ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ )} + + {/* Skills list */} + {packDetail && packDetail.skills.length > 0 && ( + +
+ + + + Name + Description + Harness + Version + + + + {packDetail.skills.map((skill) => ( + + {skill.name ?? skill.skillId} + + {skill.description ?? "-"} + + + {skill.harness ? {skill.harness} : "-"} + + {skill.version ?? "-"} + + ))} + + +
+
+ )} + + {/* Project associations */} + {packDetail && packDetail.associations.length > 0 && ( + +
+ {packDetail.associations.map((assoc) => ( +
+ {assoc.projectPath} + + {formatDate(assoc.lastSeenAt)} + +
+ ))} +
+
+ )} + + {/* README */} + {readme && ( + +
+ {readme} +
+
+ )} + + {/* Contents */} + {catalogEntry?.contentsCache && catalogEntry.contentsCache.length > 0 && ( + +
+ + + + Name + Type + Description + + + + {catalogEntry.contentsCache.map((item) => ( + + {item.name} + + {item.type} + + + {item.description ?? "-"} + + + ))} + + +
+
+ )} +
+ ); +} + +function formatDate(value: string | null | undefined): string { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); +} diff --git a/apps/desktop/src/renderer/components/features/PlansView.tsx b/apps/desktop/src/renderer/components/features/PlansView.tsx new file mode 100644 index 00000000..a1acc3ce --- /dev/null +++ b/apps/desktop/src/renderer/components/features/PlansView.tsx @@ -0,0 +1,322 @@ +import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; +import { Button } from "@closedloop-ai/design-system/components/ui/button"; +import { EmptyState } from "@closedloop-ai/design-system/components/ui/empty-state"; +import { + Check, + ChevronRight, + ClipboardList, + ExternalLink, + History, + X, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import type { PlanRecord, PlanVersionRecord } from "../../../shared/agent-db-contract"; +import { useQueryCache, invalidateCache } from "../../hooks/useQueryCache"; +import { DashboardCard, LoadingState, PageShell, cx } from "../layout/page-shell"; + +export function PlansView() { + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [showVersions, setShowVersions] = useState(false); + + const { data: plans, loading } = useQueryCache( + "db:plans-list", + () => window.desktopApi.db.getPlansList(), + 5_000, + 10_000, + ); + + const selectedPlan = useMemo( + () => plans?.find((p) => p.id === selectedPlanId) ?? null, + [plans, selectedPlanId], + ); + + const { data: versions } = useQueryCache( + `db:plan-versions:${selectedPlanId}`, + () => + selectedPlanId + ? window.desktopApi.db.getPlanVersions(selectedPlanId) + : Promise.resolve([]), + 10_000, + 30_000, + ); + + const handleSelect = useCallback((id: string) => { + setSelectedPlanId(id); + setShowVersions(false); + }, []); + + const handleConfirm = useCallback(async (id: string) => { + try { + await window.desktopApi.db.confirmPlan(id); + invalidateCache("db:plans-list"); + } catch (err) { + console.error("Confirm plan failed:", err); + } + }, []); + + const handleReject = useCallback(async (id: string) => { + try { + await window.desktopApi.db.rejectPlan(id); + invalidateCache("db:plans-list"); + } catch (err) { + console.error("Reject plan failed:", err); + } + }, []); + + const handleOpenPlan = useCallback(async (id: string) => { + try { + await window.desktopApi.db.openPlan(id); + } catch (err) { + console.error("Open plan failed:", err); + } + }, []); + + if (loading && !plans) { + return ; + } + + const planList = plans ?? []; + + return ( + + {planList.length === 0 ? ( + + ) : ( +
+ {/* Left column: plan list */} + +
+ {planList.map((plan) => ( + + ))} +
+
+ + {/* Right column: detail pane */} +
+ {selectedPlan ? ( + <> + + + {/* Version toggle */} + {selectedPlan.versionCount > 0 && ( + + )} + + {/* Version history */} + {showVersions && versions && versions.length > 0 && ( + +
+ {versions.map((v) => ( + + ))} +
+
+ )} + + ) : ( + +
+ Select a plan to view details +
+
+ )} +
+
+ )} +
+ ); +} + +// ---- Plan row in the list ---- + +function PlanRow({ + plan, + selected, + onSelect, +}: { + plan: PlanRecord; + selected: boolean; + onSelect: (id: string) => void; +}) { + return ( + + ); +} + +// ---- Detail pane ---- + +function PlanDetail({ + plan, + onConfirm, + onReject, + onOpen, +}: { + plan: PlanRecord; + onConfirm: (id: string) => void; + onReject: (id: string) => void; + onOpen: (id: string) => void; +}) { + return ( + +
+ {/* Header */} +
+
+

{plan.title ?? "Untitled Plan"}

+
+ + {plan.confidence > 0 && ( + Confidence: {Math.round(plan.confidence * 100)}% + )} + {plan.captureMethod && Capture: {plan.captureMethod}} + {plan.harness && {plan.harness}} + {plan.source && {plan.source}} +
+
+
+ + {/* Metadata */} +
+ {plan.createdAt && Created: {formatDate(plan.createdAt)}} + {plan.updatedAt && Updated: {formatDate(plan.updatedAt)}} + {plan.filePath && File: {plan.filePath}} +
+ + {/* Action buttons */} +
+ {plan.needsConfirmation && plan.status !== "confirmed" && plan.status !== "rejected" && ( + <> + + + + )} + +
+ + {/* Content preview */} + {plan.latestContent && ( +
+
{plan.latestContent}
+
+ )} +
+
+ ); +} + +// ---- Version entry ---- + +function VersionEntry({ version }: { version: PlanVersionRecord }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + {expanded && version.contentMarkdown && ( +
+
{version.contentMarkdown}
+
+ )} +
+ ); +} + +// ---- Helpers ---- + +function StatusBadge({ + status, + needsConfirmation, +}: { + status: string; + needsConfirmation: boolean; +}) { + if (needsConfirmation && status !== "confirmed" && status !== "rejected") { + return ( + + Needs Confirmation + + ); + } + + const variant = status === "confirmed" + ? "default" + : status === "rejected" + ? "destructive" + : "outline"; + + return {status}; +} + +function formatDate(value: string | null | undefined): string { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); +} diff --git a/apps/desktop/src/renderer/components/features/PullRequestsView.tsx b/apps/desktop/src/renderer/components/features/PullRequestsView.tsx new file mode 100644 index 00000000..56dcaac8 --- /dev/null +++ b/apps/desktop/src/renderer/components/features/PullRequestsView.tsx @@ -0,0 +1,281 @@ +import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; +import { Button } from "@closedloop-ai/design-system/components/ui/button"; +import { EmptyState } from "@closedloop-ai/design-system/components/ui/empty-state"; +import { MetricCard } from "@closedloop-ai/design-system/components/ui/primitives/metric-card"; +import { + Table as DsTable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@closedloop-ai/design-system/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@closedloop-ai/design-system/components/ui/tabs"; +import { + ExternalLink, + GitPullRequest, + Layers, + FolderGit2, +} from "lucide-react"; +import { useCallback } from "react"; +import type { PrRecord, PrSessionGroup, PrStats } from "../../../shared/agent-db-contract"; +import { useQueryCache } from "../../hooks/useQueryCache"; +import { + DASHBOARD_METRIC_CARD_CLASS_NAME, + DASHBOARD_TABLE_CLASS_NAME, + DashboardCard, + LoadingState, + PageShell, + cx, +} from "../layout/page-shell"; + +export function PullRequestsView() { + const { data: stats, loading: statsLoading } = useQueryCache( + "db:pr-stats", + () => window.desktopApi.db.getPrStats(), + 5_000, + 10_000, + ); + + const { data: sessions, loading: sessionsLoading } = useQueryCache( + "db:pr-sessions", + () => window.desktopApi.db.getPrSessions(), + 5_000, + 10_000, + ); + + const { data: allPrs, loading: prsLoading } = useQueryCache( + "db:pr-list", + () => window.desktopApi.db.getPrList(), + 5_000, + 10_000, + ); + + const handleOpenPr = useCallback(async (id: string) => { + try { + await window.desktopApi.db.openPr(id); + } catch (err) { + console.error("Open PR failed:", err); + } + }, []); + + if (statsLoading && !stats) { + return ; + } + + const prStats = stats ?? { totalPrs: 0, sessionsWithPrs: 0, repos: 0 }; + + return ( + + {/* Summary stat pills */} +
+ + + +
+ + {prStats.totalPrs === 0 ? ( + + ) : ( + + + By Session + All PRs + + + + {!sessionsLoading && sessions && sessions.length > 0 ? ( +
+ {sessions.map((group) => ( + + ))} +
+ ) : sessionsLoading ? ( + + ) : ( + + )} +
+ + + {!prsLoading && allPrs && allPrs.length > 0 ? ( + + ) : prsLoading ? ( + + ) : ( + + )} + +
+ )} +
+ ); +} + +// ---- Session group card ---- + +function SessionGroupCard({ + group, + onOpenPr, +}: { + group: PrSessionGroup; + onOpenPr: (id: string) => void; +}) { + return ( + +
+ {/* Session header */} +
+
+

+ {group.sessionName ?? group.sessionId} +

+
+ {group.harness && {group.harness}} + {group.cwd && {group.cwd}} + {group.startedAt && {formatDate(group.startedAt)}} +
+
+ + {group.prs.length} PR{group.prs.length !== 1 ? "s" : ""} + +
+ + {/* PR chips */} +
+ {group.prs.map((pr) => ( + + ))} +
+
+
+ ); +} + +// ---- PR chip ---- + +function PrChip({ + pr, + onOpen, +}: { + pr: PrRecord; + onOpen: (id: string) => void; +}) { + const label = pr.repoFullName + ? `${pr.repoFullName}#${pr.prNumber ?? "?"}` + : `#${pr.prNumber ?? pr.id.slice(0, 8)}`; + + return ( + + ); +} + +// ---- Flat PR table ---- + +function PrTable({ + prs, + onOpenPr, +}: { + prs: PrRecord[]; + onOpenPr: (id: string) => void; +}) { + return ( + +
+ + + + Pull Request + Repo + Branch + Harness + Observed + + + + + {prs.map((pr) => ( + + + + #{pr.prNumber ?? "?"} + {pr.title ? ` ${pr.title}` : ""} + + + + {pr.repoFullName ?? "-"} + + + {pr.branchName ?? "-"} + + + {pr.harness ? {pr.harness} : "-"} + + + {formatDate(pr.observedAt)} + + + + + + ))} + + +
+
+ ); +} + +// ---- Helpers ---- + +function formatDate(value: string | null | undefined): string { + if (!value) return "-"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); +} diff --git a/apps/desktop/src/renderer/components/features/Sparkline.tsx b/apps/desktop/src/renderer/components/features/Sparkline.tsx new file mode 100644 index 00000000..0d38a50e --- /dev/null +++ b/apps/desktop/src/renderer/components/features/Sparkline.tsx @@ -0,0 +1,51 @@ +/** + * Pure inline SVG sparkline -- 80x20 polyline, zero chart dependencies. + */ +export function Sparkline({ + data, + width = 80, + height = 20, + color = "var(--primary)", +}: { + data: number[]; + width?: number; + height?: number; + color?: string; +}) { + if (data.length < 2) return null; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + + const padding = 1; + const innerW = width - padding * 2; + const innerH = height - padding * 2; + + const points = data + .map((v, i) => { + const x = padding + (i / (data.length - 1)) * innerW; + const y = padding + innerH - ((v - min) / range) * innerH; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/types/desktop-api.d.ts b/apps/desktop/src/renderer/types/desktop-api.d.ts index cbb08621..93c5f8c2 100644 --- a/apps/desktop/src/renderer/types/desktop-api.d.ts +++ b/apps/desktop/src/renderer/types/desktop-api.d.ts @@ -20,6 +20,17 @@ import type { AnalyticsData, WorkflowQueryData, AgentHierarchyNode, + CatalogEntry, + InstallRunRecord, + InstalledPack, + InstalledPackDetail, + SkillWithInvocations, + SkillInvocation, + PlanRecord, + PlanVersionRecord, + PrStats, + PrSessionGroup, + PrRecord, } from "../../shared/agent-db-contract"; export interface AgentMonitorUrl { @@ -135,9 +146,44 @@ export interface DesktopApi { getSubAgents: () => Promise; getPlans: () => Promise; getPullRequests: () => Promise; + + // Catalog (FEA-1314) + getCatalog: () => Promise; + getCatalogEntry: (packId: string) => Promise; + getCatalogReadme: (packId: string) => Promise; + getCatalogContents: (packId: string) => Promise; + getCatalogHistory: (packId: string) => Promise>; + catalogInstall: (packId: string, harness: string, cwd?: string) => Promise<{ runId: number }>; + catalogUninstall: (packId: string, harness: string) => Promise<{ runId: number }>; + catalogRefresh: () => Promise; + getInstallRuns: (packId?: string) => Promise; + + // Installed packs (FEA-1224) + getInstalledPacks: () => Promise; + getPackDetail: (packId: string) => Promise; + getPackSessions: (packId: string) => Promise; + getAllSkills: () => Promise; + getSkillInvocations: (name: string) => Promise; + getRecentProjects: () => Promise; + + // Plans (FEA-1189) + getPlansList: (opts?: { sessionId?: string; needsConfirmation?: boolean; limit?: number; offset?: number }) => Promise; + getPlan: (id: string) => Promise; + getPlanVersions: (planId: string) => Promise; + confirmPlan: (id: string) => Promise; + rejectPlan: (id: string) => Promise; + openPlan: (id: string, target?: string) => Promise; + + // Pull Requests (FEA-1226) + getPrStats: () => Promise; + getPrSessions: (opts?: { limit?: number; offset?: number }) => Promise; + getPrList: (opts?: { sessionId?: string; repo?: string; limit?: number; offset?: number }) => Promise; + openPr: (id: string) => Promise; }; /** Live DB-change push subscription; returns an unsubscribe fn. */ onDbChanged: (callback: (payload: { sessionId?: string }) => void) => () => void; + /** Subscribe to streamed pack install/uninstall output (FEA-1314). */ + onInstallOutput?: (callback: (payload: { runId: number; type: string; data: string }) => void) => () => void; } declare global { diff --git a/apps/desktop/src/shared/agent-db-contract.ts b/apps/desktop/src/shared/agent-db-contract.ts index e5ce6dbb..05623bd7 100644 --- a/apps/desktop/src/shared/agent-db-contract.ts +++ b/apps/desktop/src/shared/agent-db-contract.ts @@ -283,3 +283,179 @@ export interface DashboardCoreFeatures { plans: DashboardPlanSummary[]; pullRequests: DashboardPullRequestSummary[]; } + +// --- Catalog (FEA-1314) --- + +export interface CatalogEntry { + packId: string; + displayName: string; + category: string | null; + githubUrl: string; + marketplaceUrl: string | null; + description: string | null; + descriptionLive: string | null; + harnesses: string[]; + installCommands: Record | null; + uninstallCommands: Record | null; + installNotes: string | null; + placeholderReason: string | null; + verified: boolean; + readmeExcerpt: string | null; + stars: number | null; + forks: number | null; + lastRelease: string | null; + seedVersion: number; + pinOrder: number | null; + contents: CatalogContentsConfig | null; + contentsCache: CatalogContentItem[] | null; + detectionPatterns: string[] | null; + harnessAgnostic: boolean; + projectScoped: boolean; + singleInstall: boolean; + postInstall: Record | null; + // Joined from agent_packs + installedHarnesses: string[]; + skillCount: number; + usageCount: number; + // Sparkline data + history: Array<{ fetchedAt: string; stars: number; forks: number }>; +} + +export interface CatalogContentsConfig { + type: string; + [key: string]: unknown; +} + +export interface CatalogContentItem { + name: string; + type: string; + description?: string; + path?: string; +} + +export interface InstallRunRecord { + id: number; + packId: string; + harness: string | null; + action: string; + command: string | null; + exitCode: number | null; + startedAt: string; + endedAt: string | null; + stdoutTail: string | null; + stderrTail: string | null; +} + +// --- Installed Packs (FEA-1224) --- + +export interface InstalledPack { + packId: string; + harnesses: string[]; + installs: Array<{ + harness: string; + installPath: string; + installKind: string | null; + sourceUrl: string | null; + version: string | null; + detectedAt: string | null; + lastSeenAt: string | null; + }>; + skillCount: number; + lastSeenAt: string | null; +} + +export interface InstalledPackDetail extends InstalledPack { + skills: Array<{ + skillId: string; + name: string | null; + version: string | null; + description: string | null; + harness: string | null; + }>; + associations: Array<{ + projectPath: string; + detectedAt: string | null; + lastSeenAt: string | null; + }>; +} + +export interface SkillWithInvocations { + skillId: string; + packId: string | null; + name: string; + harness: string | null; + description: string | null; + invocationCount: number; + lastUsedAt: string | null; +} + +export interface SkillInvocation { + eventId: string; + sessionId: string; + sessionName: string | null; + harness: string | null; + model: string | null; + createdAt: string | null; +} + +// --- Plans (FEA-1189) --- + +export interface PlanRecord { + id: string; + title: string | null; + status: string; + source: string | null; + captureMethod: string | null; + harness: string | null; + sessionId: string | null; + filePath: string | null; + sourceLogPath: string | null; + needsConfirmation: boolean; + confidence: number; + createdAt: string | null; + updatedAt: string | null; + latestContent: string | null; + versionCount: number; +} + +export interface PlanVersionRecord { + id: string; + planId: string; + versionNumber: number; + contentMarkdown: string | null; + contentSha256: string | null; + authorType: string | null; + captureMethod: string | null; + createdAt: string | null; +} + +// --- Pull Requests (FEA-1226) --- + +export interface PrRecord { + id: string; + sessionId: string | null; + prUrl: string; + prNumber: number | null; + repoFullName: string | null; + branchName: string | null; + headSha: string | null; + title: string | null; + harness: string | null; + observedAt: string | null; + createdAt: string | null; +} + +export interface PrStats { + totalPrs: number; + sessionsWithPrs: number; + repos: number; +} + +export interface PrSessionGroup { + sessionId: string; + sessionName: string | null; + cwd: string | null; + harness: string | null; + startedAt: string | null; + prs: PrRecord[]; +} From af76dfd4bb9a34dc920f74231cac8263c429448e Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:04:02 -0500 Subject: [PATCH 14/20] FEA-1550: Fix sidebar icon sizing and CSS import order - Import design-system globals.css before local globals.css so DS styles are the base layer and local overrides apply on top - Replace arbitrary size-[18px] span wrapper with standard size-4 class directly on SVG elements, matching the design system's own icon sizing convention and ensuring Tailwind generates the class Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/renderer/components/layout/Sidebar.tsx | 4 ++-- apps/desktop/src/renderer/main.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/components/layout/Sidebar.tsx index 33c388ca..13da7b1c 100644 --- a/apps/desktop/src/renderer/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/layout/Sidebar.tsx @@ -48,8 +48,8 @@ const SVG_ICONS: Record = { function NavIcon({ name }: { name: string }) { const paths = SVG_ICONS[name] || ""; return ( - - + + diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index d89165c6..d9b44590 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { DesignSystemProvider } from "@closedloop-ai/design-system"; -import "./globals.css"; import "@closedloop-ai/design-system/styles/globals.css"; +import "./globals.css"; import App from "./App"; window.addEventListener("error", (event) => { From d11071c1b614afb6138a0fe97fc155bed7d422ee Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:10:41 -0500 Subject: [PATCH 15/20] FEA-1550: Add missing sidecar animations and font fallbacks to renderer CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fade-in, slide-up, and pulse-slow animations via @theme inline (ported from sidecar tailwind.config.js, candidates for DS upstream) - Extend font fallback chains to match sidecar (Inter, Fira Code) The design system already provides surface-*, accent, border color tokens via @theme inline — no color additions needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/renderer/globals.css | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index 301b079d..23a28bd0 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -1,6 +1,23 @@ @source "./**/*.{ts,tsx}"; :root { - --font-sans: "Geist", "Avenir Next", "Segoe UI", system-ui, sans-serif; - --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --font-sans: "Geist", "Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", system-ui, sans-serif; + --font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* Animations ported from the sidecar Tailwind config — candidates for + upstream contribution to @closedloop-ai/design-system. */ +@theme inline { + --animate-fade-in: fadeIn 0.3s ease-out; + --animate-slide-up: slideUp 0.3s ease-out; + --animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } } From cc1980cc22d794f709b677469ff63ed69cabb754 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:16:45 -0500 Subject: [PATCH 16/20] FEA-1550: Fix pack install UI contract - Align pack install IPC results and streamed output payloads across main, preload, and renderer types - Handle failed catalog mutation starts without opening a stale install modal - Pass selected recent project cwd through project-scoped install and uninstall actions - Keep local-only agent session user and org identity explicitly null until server-owned identity is available Testing: Desktop typecheck, lint, and renderer build passed Risks: Low; scoped to pack catalog install UI flow and typed IPC contracts --- .../agent-dashboard-design-system-runtime.ts | 3 +- apps/desktop/src/main/app.ts | 2 + .../desktop/src/main/preload-design-system.ts | 10 +- .../components/features/InstallModal.tsx | 50 ++++- .../components/features/PacksCatalog.tsx | 193 +++++++++++++++++- .../src/renderer/types/desktop-api.d.ts | 8 +- apps/desktop/src/shared/agent-db-contract.ts | 15 ++ 7 files changed, 260 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index c72da80b..0e369579 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -400,12 +400,13 @@ function registerDesignSystemDbIpcHandlers( }); }); - ipcMain.handle("desktop:db:catalog-uninstall", async (_event, packId: unknown, harness: unknown) => { + ipcMain.handle("desktop:db:catalog-uninstall", async (_event, packId: unknown, harness: unknown, cwd?: unknown) => { if (typeof packId !== "string" || typeof harness !== "string") return { started: false }; return streamRun(dbForStores, { pack_id: packId, harness, action: "uninstall", + cwd: typeof cwd === "string" ? cwd : undefined, getWindow: options.getWindow, onComplete: () => void runPackScanner(dbForStores).catch(() => {}), }); diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index d793e0d2..56ba7267 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -1447,6 +1447,8 @@ export class DesktopApplication { await createAgentDashboardDesignSystemRuntime({ userDataPath: app.getPath("userData"), getWindow: () => this.desktopWindow.getWindow(), + // User/org IDs are server-owned. Local-only sessions keep these columns null. + getUserIdentity: () => null, onTerminalFailure: (reason) => { const notification = new Notification({ title: "ClosedLoop Agent Monitor", diff --git a/apps/desktop/src/main/preload-design-system.ts b/apps/desktop/src/main/preload-design-system.ts index 875cd457..8ee6eefd 100644 --- a/apps/desktop/src/main/preload-design-system.ts +++ b/apps/desktop/src/main/preload-design-system.ts @@ -4,6 +4,7 @@ import type { AgentRow, AnalyticsData, CatalogEntry, + CatalogMutationResult, DashboardCoreFeatures, DashboardPackSummary, DashboardPlanSummary, @@ -15,6 +16,7 @@ import type { EventCountByType, EventRow, EventWithSession, + InstallOutputChunk, InstallRunRecord, InstalledPack, InstalledPackDetail, @@ -67,8 +69,8 @@ const designSystemDashboardApi = { getCatalogReadme: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-readme", packId) as Promise, getCatalogContents: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-contents", packId) as Promise, getCatalogHistory: (packId: string) => ipcRenderer.invoke("desktop:db:get-catalog-history", packId) as Promise>, - catalogInstall: (packId: string, harness: string, cwd?: string) => ipcRenderer.invoke("desktop:db:catalog-install", packId, harness, cwd) as Promise<{ runId: number }>, - catalogUninstall: (packId: string, harness: string) => ipcRenderer.invoke("desktop:db:catalog-uninstall", packId, harness) as Promise<{ runId: number }>, + catalogInstall: (packId: string, harness: string, cwd?: string) => ipcRenderer.invoke("desktop:db:catalog-install", packId, harness, cwd) as Promise, + catalogUninstall: (packId: string, harness: string, cwd?: string) => ipcRenderer.invoke("desktop:db:catalog-uninstall", packId, harness, cwd) as Promise, catalogRefresh: () => ipcRenderer.invoke("desktop:db:catalog-refresh") as Promise, getInstallRuns: (packId?: string) => ipcRenderer.invoke("desktop:db:get-install-runs", packId) as Promise, @@ -107,8 +109,8 @@ const designSystemDashboardApi = { return () => ipcRenderer.removeListener("desktop:db:changed", handler); }, /** Subscribe to streamed pack install/uninstall output (FEA-1314). */ - onInstallOutput: (callback: (payload: { runId: number; type: string; data: string }) => void) => { - const handler = (_event: unknown, payload: { runId: number; type: string; data: string }) => + onInstallOutput: (callback: (payload: InstallOutputChunk) => void) => { + const handler = (_event: unknown, payload: InstallOutputChunk) => callback(payload); ipcRenderer.on("desktop:pack:install-output", handler); return () => ipcRenderer.removeListener("desktop:pack:install-output", handler); diff --git a/apps/desktop/src/renderer/components/features/InstallModal.tsx b/apps/desktop/src/renderer/components/features/InstallModal.tsx index 20665b55..efa19698 100644 --- a/apps/desktop/src/renderer/components/features/InstallModal.tsx +++ b/apps/desktop/src/renderer/components/features/InstallModal.tsx @@ -53,14 +53,18 @@ export function InstallModal({ const unsubscribe = window.desktopApi.onInstallOutput?.((payload) => { if (payload.runId !== runId) return; - if (payload.type === "exit") { - const code = parseInt(payload.data, 10); - setExitCode(Number.isNaN(code) ? null : code); + if (payload.type === "complete") { + const code = extractExitCode(payload.data); setDone(true); + setExitCode(code); return; } - setLines((prev) => [...prev, { type: payload.type, data: payload.data }]); + const rendered = formatOutputPayload(payload.type, payload.data); + if (!rendered) { + return; + } + setLines((prev) => [...prev, { type: payload.type, data: rendered }]); }); return () => { @@ -166,3 +170,41 @@ export function InstallModal({ ); } + +function extractExitCode(data: unknown): number | null { + if (typeof data === "number" && Number.isFinite(data)) { + return data; + } + if (typeof data === "string") { + const parsed = Number.parseInt(data, 10); + return Number.isNaN(parsed) ? null : parsed; + } + if (data && typeof data === "object" && "exit_code" in data) { + const value = (data as { exit_code?: unknown }).exit_code; + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + return null; +} + +function formatOutputPayload(type: string, data: unknown): string | null { + if (type === "start") { + return null; + } + if (typeof data === "string") { + return data; + } + if (data && typeof data === "object" && "message" in data) { + const message = (data as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + if (type === "post_install" || type === "copy_command" || type === "error") { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + } + return null; +} diff --git a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx index afff5acf..a30c4966 100644 --- a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx +++ b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx @@ -2,6 +2,13 @@ import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; import { Button } from "@closedloop-ai/design-system/components/ui/button"; import { EmptyState } from "@closedloop-ai/design-system/components/ui/empty-state"; import { Input } from "@closedloop-ai/design-system/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@closedloop-ai/design-system/components/ui/select"; import { Table as DsTable, TableBody, @@ -25,9 +32,10 @@ import { Star, GitFork, } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { CatalogEntry, + CatalogMutationResult, InstalledPack, InstalledPackDetail, } from "../../../shared/agent-db-contract"; @@ -63,6 +71,8 @@ const EMPTY_INSTALL: InstallState = { command: null, }; +const PROJECT_RELATIVE_HINTS = ["--directory .", "--directory=.", " -C ."]; + export function PacksCatalog() { const [search, setSearch] = useState(""); const [viewMode, setViewMode] = useState("catalog"); @@ -70,6 +80,8 @@ export function PacksCatalog() { const [installing, setInstalling] = useState>({}); const [installModal, setInstallModal] = useState(EMPTY_INSTALL); const [refreshing, setRefreshing] = useState(false); + const [selectedProjectCwd, setSelectedProjectCwd] = useState(""); + const [installError, setInstallError] = useState(null); // -- Data fetching -- @@ -94,6 +106,13 @@ export function PacksCatalog() { 10_000, ); + const { data: recentProjects } = useQueryCache( + "db:recent-projects", + () => window.desktopApi.db.getRecentProjects(), + 10_000, + 30_000, + ); + // -- Filtering -- const lowerSearch = search.toLowerCase(); @@ -120,39 +139,75 @@ export function PacksCatalog() { [filteredCatalog], ); + const hasProjectScopedActions = useMemo( + () => + filteredCatalog.some((entry) => + entry.harnesses.some( + (harness) => + requiresProjectCwd(entry, harness, "install") || + requiresProjectCwd(entry, harness, "uninstall"), + ), + ), + [filteredCatalog], + ); + + useEffect(() => { + if (!selectedProjectCwd && recentProjects?.[0]) { + setSelectedProjectCwd(recentProjects[0]); + } + }, [recentProjects, selectedProjectCwd]); + // -- Actions -- const handleInstall = useCallback(async (packId: string, harness: string) => { const key = `${packId}:${harness}`; setInstalling((prev) => ({ ...prev, [key]: true })); + setInstallError(null); try { const entry = catalog?.find((e) => e.packId === packId); const command = entry?.installCommands?.[harness] ?? null; - const { runId } = await window.desktopApi.db.catalogInstall(packId, harness); - setInstallModal({ open: true, packId, harness, action: "install", runId, command }); + const cwd = resolveProjectCwdForAction(entry, harness, "install", selectedProjectCwd); + if (cwd === "missing") { + setInstallError(`Select a recent project before installing ${packId}.`); + return; + } + const result = await window.desktopApi.db.catalogInstall(packId, harness, cwd ?? undefined); + if (!handleCatalogMutationResult(result, setInstallError)) { + return; + } + setInstallModal({ open: true, packId, harness, action: "install", runId: result.runId ?? null, command }); } catch (err) { - console.error("Install failed:", err); + setInstallError(err instanceof Error ? err.message : "Install failed."); } finally { setInstalling((prev) => ({ ...prev, [key]: false })); } - }, [catalog]); + }, [catalog, selectedProjectCwd]); const handleUninstall = useCallback(async (packId: string, harness: string) => { const key = `${packId}:${harness}`; setInstalling((prev) => ({ ...prev, [key]: true })); + setInstallError(null); try { const entry = catalog?.find((e) => e.packId === packId); const command = entry?.uninstallCommands?.[harness] ?? null; - const { runId } = await window.desktopApi.db.catalogUninstall(packId, harness); - setInstallModal({ open: true, packId, harness, action: "uninstall", runId, command }); + const cwd = resolveProjectCwdForAction(entry, harness, "uninstall", selectedProjectCwd); + if (cwd === "missing") { + setInstallError(`Select a recent project before uninstalling ${packId}.`); + return; + } + const result = await window.desktopApi.db.catalogUninstall(packId, harness, cwd ?? undefined); + if (!handleCatalogMutationResult(result, setInstallError)) { + return; + } + setInstallModal({ open: true, packId, harness, action: "uninstall", runId: result.runId ?? null, command }); } catch (err) { - console.error("Uninstall failed:", err); + setInstallError(err instanceof Error ? err.message : "Uninstall failed."); } finally { setInstalling((prev) => ({ ...prev, [key]: false })); } - }, [catalog]); + }, [catalog, selectedProjectCwd]); const handleCloseModal = useCallback(() => { setInstallModal(EMPTY_INSTALL); @@ -200,6 +255,10 @@ export function PacksCatalog() { onInstall={handleInstall} onUninstall={handleUninstall} installing={installing} + recentProjects={recentProjects ?? []} + selectedProjectCwd={selectedProjectCwd} + onProjectCwdChange={setSelectedProjectCwd} + installError={installError} /> ); } @@ -219,12 +278,26 @@ export function PacksCatalog() { className="pl-9" />
+ {hasProjectScopedActions && ( + + )}
+ {installError && ( +
+ {installError} +
+ )} + @@ -374,6 +447,10 @@ function PackDetailView({ onInstall, onUninstall, installing, + recentProjects, + selectedProjectCwd, + onProjectCwdChange, + installError, }: { packId: string; catalogEntry: CatalogEntry | null; @@ -382,6 +459,10 @@ function PackDetailView({ onInstall: (packId: string, harness: string) => void; onUninstall: (packId: string, harness: string) => void; installing: Record; + recentProjects: string[]; + selectedProjectCwd: string; + onProjectCwdChange: (cwd: string) => void; + installError: string | null; }) { const { data: readme } = useQueryCache( `db:catalog-readme:${packId}`, @@ -393,6 +474,11 @@ function PackDetailView({ const displayName = catalogEntry?.displayName ?? packId; const description = catalogEntry?.descriptionLive ?? catalogEntry?.description; const starHistory = catalogEntry?.history?.map((h) => h.stars) ?? []; + const hasProjectScopedActions = catalogEntry?.harnesses.some( + (harness) => + requiresProjectCwd(catalogEntry, harness, "install") || + requiresProjectCwd(catalogEntry, harness, "uninstall"), + ) ?? false; return ( @@ -434,6 +520,23 @@ function PackDetailView({
)} + {installError && ( +
+ {installError} +
+ )} + + {hasProjectScopedActions && ( + + + + )} + {/* Harnesses + install actions */} {catalogEntry && ( @@ -572,3 +675,75 @@ function formatDate(value: string | null | undefined): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } + +function ProjectCwdSelect({ + recentProjects, + selectedProjectCwd, + onProjectCwdChange, + className, +}: { + recentProjects: string[]; + selectedProjectCwd: string; + onProjectCwdChange: (cwd: string) => void; + className?: string; +}) { + return ( + + ); +} + +function requiresProjectCwd( + entry: CatalogEntry | null | undefined, + harness: string, + action: "install" | "uninstall", +): boolean { + if (!entry) { + return false; + } + const commandMap = action === "install" ? entry.installCommands : entry.uninstallCommands; + const command = commandMap?.[harness]; + return ( + entry.projectScoped || + (typeof command === "string" && + PROJECT_RELATIVE_HINTS.some((hint) => command.includes(hint))) + ); +} + +function resolveProjectCwdForAction( + entry: CatalogEntry | null | undefined, + harness: string, + action: "install" | "uninstall", + selectedProjectCwd: string, +): string | null | "missing" { + if (!requiresProjectCwd(entry, harness, action)) { + return null; + } + return selectedProjectCwd || "missing"; +} + +function handleCatalogMutationResult( + result: CatalogMutationResult, + setInstallError: (message: string | null) => void, +): result is CatalogMutationResult & { started: true; runId: number } { + if (result.started && typeof result.runId === "number") { + return true; + } + setInstallError(result.error?.message ?? "Pack operation did not start."); + return false; +} diff --git a/apps/desktop/src/renderer/types/desktop-api.d.ts b/apps/desktop/src/renderer/types/desktop-api.d.ts index 93c5f8c2..a3a83da0 100644 --- a/apps/desktop/src/renderer/types/desktop-api.d.ts +++ b/apps/desktop/src/renderer/types/desktop-api.d.ts @@ -21,6 +21,8 @@ import type { WorkflowQueryData, AgentHierarchyNode, CatalogEntry, + CatalogMutationResult, + InstallOutputChunk, InstallRunRecord, InstalledPack, InstalledPackDetail, @@ -153,8 +155,8 @@ export interface DesktopApi { getCatalogReadme: (packId: string) => Promise; getCatalogContents: (packId: string) => Promise; getCatalogHistory: (packId: string) => Promise>; - catalogInstall: (packId: string, harness: string, cwd?: string) => Promise<{ runId: number }>; - catalogUninstall: (packId: string, harness: string) => Promise<{ runId: number }>; + catalogInstall: (packId: string, harness: string, cwd?: string) => Promise; + catalogUninstall: (packId: string, harness: string, cwd?: string) => Promise; catalogRefresh: () => Promise; getInstallRuns: (packId?: string) => Promise; @@ -183,7 +185,7 @@ export interface DesktopApi { /** Live DB-change push subscription; returns an unsubscribe fn. */ onDbChanged: (callback: (payload: { sessionId?: string }) => void) => () => void; /** Subscribe to streamed pack install/uninstall output (FEA-1314). */ - onInstallOutput?: (callback: (payload: { runId: number; type: string; data: string }) => void) => () => void; + onInstallOutput?: (callback: (payload: InstallOutputChunk) => void) => () => void; } declare global { diff --git a/apps/desktop/src/shared/agent-db-contract.ts b/apps/desktop/src/shared/agent-db-contract.ts index 05623bd7..f9b2099a 100644 --- a/apps/desktop/src/shared/agent-db-contract.ts +++ b/apps/desktop/src/shared/agent-db-contract.ts @@ -346,6 +346,21 @@ export interface InstallRunRecord { stderrTail: string | null; } +export interface CatalogMutationResult { + started: boolean; + runId?: number; + error?: { + code: string; + message: string; + }; +} + +export interface InstallOutputChunk { + runId: number; + type: "start" | "stdout" | "stderr" | "error" | "post_install" | "copy_command" | "complete"; + data: unknown; +} + // --- Installed Packs (FEA-1224) --- export interface InstalledPack { From 0252b1393a154badfdceb578dfd9c4386557f554 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:38:37 -0500 Subject: [PATCH 17/20] FEA-1550: Stabilize dashboard startup and PRD worktrees - Register dashboard database IPC handlers before PGlite startup completes and wait on the shared database promise per invocation. - Route pack scanner/catalog warnings through gateway logging and resolve catalog probe binaries through the login-shell resolver. - Retry stale worktree directory removal and fall back from invalid Git overrides before materializing PRD branches. - Add focused guards for early IPC registration, binary resolution fallback, and stale PRD worktree cleanup. Testing: Full desktop test suite, desktop typecheck, and desktop lint passed. Risks: Low; dashboard IPC now backpressures on database startup, and invalid Git overrides fall back to the login-shell Git path. --- .../agent-dashboard-design-system-runtime.ts | 289 +++++++++++------- .../desktop/src/main/packs/catalog-fetcher.ts | 21 +- apps/desktop/src/main/packs/pack-scanner.ts | 83 ++--- .../src/server/operations/symphony-loop.ts | 14 +- apps/desktop/src/server/shell-path.ts | 10 +- .../test/agent-dashboard-boundary.test.ts | 13 + .../symphony-loop-binary-resolution.test.ts | 12 + ...mphony-loop-branch-materialization.test.ts | 47 +++ 8 files changed, 310 insertions(+), 179 deletions(-) diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 0e369579..be61f75b 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -1,5 +1,10 @@ import path from "node:path"; -import { app, ipcMain, type BrowserWindow } from "electron"; +import { + app, + ipcMain, + type BrowserWindow, + type IpcMainInvokeEvent, +} from "electron"; import { AgentHookListener } from "./agent-monitor-listener.js"; import { CollectorManager } from "./collectors/collector-manager.js"; import type { MeteredUsageRow } from "./reconciliation-worker.js"; @@ -120,7 +125,8 @@ export async function createAgentDashboardDesignSystemRuntime( ): Promise { const log = options.log ?? (() => {}); let pgliteDatabase: PgliteAgentDatabase | null = null; - const agentDatabase = await openPgliteAgentDatabase({ + let dbIpcRegistered = false; + const agentDatabasePromise = openPgliteAgentDatabase({ dataDir: resolveAgentDashboardDatabasePath(options.userDataPath), detectBillingMode, emit: (sessionId: string) => { @@ -130,6 +136,17 @@ export async function createAgentDashboardDesignSystemRuntime( getUserIdentity: options.getUserIdentity, log: (message: string) => log("agent-pglite", message), }); + + const registerIpcHandlers = () => { + if (dbIpcRegistered) { + return; + } + dbIpcRegistered = true; + registerDesignSystemDbIpcHandlers(() => agentDatabasePromise, options); + }; + registerIpcHandlers(); + + const agentDatabase = await agentDatabasePromise; pgliteDatabase = agentDatabase; log( "agent-dashboard", @@ -169,7 +186,6 @@ export async function createAgentDashboardDesignSystemRuntime( void runCatalogFetch(dbForStores).catch(() => {}); catalogFetchTimer = scheduleCatalogFetch(dbForStores); - let dbIpcRegistered = false; let closed = false; const hookListener = new AgentHookListener({ @@ -217,7 +233,10 @@ export async function createAgentDashboardDesignSystemRuntime( clearInterval(catalogFetchTimer); catalogFetchTimer = null; } - unregisterDesignSystemDbIpcHandlers(); + if (dbIpcRegistered) { + unregisterDesignSystemDbIpcHandlers(); + dbIpcRegistered = false; + } await agentDatabase.close(); }, restartCollectors: () => { @@ -227,13 +246,7 @@ export async function createAgentDashboardDesignSystemRuntime( collectorManager.stop(); collectorManager.start(); }, - registerIpcHandlers: () => { - if (dbIpcRegistered) { - return; - } - dbIpcRegistered = true; - registerDesignSystemDbIpcHandlers(agentDatabase, options); - }, + registerIpcHandlers, loadMeteredUsageRows: (cutoffIso: string) => agentDatabase.loadMeteredUsageRows(cutoffIso), }; @@ -242,42 +255,74 @@ export async function createAgentDashboardDesignSystemRuntime( } function registerDesignSystemDbIpcHandlers( - agentDatabase: PgliteAgentDatabase, + getAgentDatabase: () => Promise, options: AgentDashboardDesignSystemRuntimeOptions, ): void { - ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll()); + unregisterDesignSystemDbIpcHandlers(); + + const withDb = + ( + handler: ( + agentDatabase: PgliteAgentDatabase, + ...args: TArgs + ) => TResult | Promise, + ) => + async (_event: IpcMainInvokeEvent, ...args: TArgs): Promise => + handler(await getAgentDatabase(), ...args); + + const withStoreDb = + ( + handler: ( + dbForStores: PgliteAgentDatabase["storeDb"], + ...args: TArgs + ) => TResult | Promise, + ) => + withDb((agentDatabase, ...args: TArgs) => + handler(agentDatabase.storeDb, ...args), + ); - ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) => - agentDatabase.sessions.getPage(coerceSessionPageRequest(request)), + ipcMain.handle( + "desktop:db:get-sessions", + withDb((agentDatabase) => agentDatabase.sessions.getAll()), ); - ipcMain.handle("desktop:db:get-kanban-pages", (_event, statuses: unknown, limit: unknown) => { - const safeStatuses = Array.isArray(statuses) ? statuses.filter((s): s is string => typeof s === "string") : []; - const safeLimit = typeof limit === "number" && Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 25; - return agentDatabase.sessions.getKanbanPages(safeStatuses, safeLimit); - }); + ipcMain.handle( + "desktop:db:get-sessions-page", + withDb((agentDatabase, request: unknown) => + agentDatabase.sessions.getPage(coerceSessionPageRequest(request)), + ), + ); - ipcMain.handle("desktop:db:get-session", (_event, id: unknown) => { + ipcMain.handle( + "desktop:db:get-kanban-pages", + withDb((agentDatabase, statuses: unknown, limit: unknown) => { + const safeStatuses = Array.isArray(statuses) ? statuses.filter((s): s is string => typeof s === "string") : []; + const safeLimit = typeof limit === "number" && Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 25; + return agentDatabase.sessions.getKanbanPages(safeStatuses, safeLimit); + }), + ); + + ipcMain.handle("desktop:db:get-session", withDb((agentDatabase, id: unknown) => { const sessionId = coerceDbId(id); if (sessionId === null) return undefined; return agentDatabase.sessions.getById(sessionId); - }); + })); - ipcMain.handle("desktop:db:get-session-details", (_event, id: unknown) => { + ipcMain.handle("desktop:db:get-session-details", withDb((agentDatabase, id: unknown) => { const sessionId = coerceDbId(id); if (sessionId === null) return undefined; return agentDatabase.sessions.getDetailsById(sessionId); - }); + })); - ipcMain.handle("desktop:db:get-agents", (_event, sessionId: unknown) => { + ipcMain.handle("desktop:db:get-agents", withDb((agentDatabase, sessionId: unknown) => { const id = coerceDbId(sessionId); if (id === null) return []; return agentDatabase.agents.getBySession(id); - }); + })); ipcMain.handle( "desktop:db:get-events", - (_event, sessionId: unknown, agentId?: unknown) => { + withDb((agentDatabase, sessionId: unknown, agentId?: unknown) => { const sid = coerceDbId(sessionId); if (sid === null) return []; const aid = coerceDbId(agentId); @@ -285,110 +330,123 @@ function registerDesignSystemDbIpcHandlers( return agentDatabase.events.getBySessionAndAgent(sid, aid); } return agentDatabase.events.getBySession(sid); - }, + }), ); - ipcMain.handle("desktop:db:get-dashboard-summary", () => - agentDatabase.getSummary(), + ipcMain.handle( + "desktop:db:get-dashboard-summary", + withDb((agentDatabase) => agentDatabase.getSummary()), ); - ipcMain.handle("desktop:db:get-sessions-with-details", () => - agentDatabase.sessions.getAllWithDetails(), + ipcMain.handle( + "desktop:db:get-sessions-with-details", + withDb((agentDatabase) => agentDatabase.sessions.getAllWithDetails()), ); - ipcMain.handle("desktop:db:get-event-feed", () => - agentDatabase.events.getAll(), + ipcMain.handle( + "desktop:db:get-event-feed", + withDb((agentDatabase) => agentDatabase.events.getAll()), ); - ipcMain.handle("desktop:db:get-events-with-session", (_event, sessionId: unknown) => { + ipcMain.handle("desktop:db:get-events-with-session", withDb((agentDatabase, sessionId: unknown) => { const id = coerceDbId(sessionId); if (id === null) return []; return agentDatabase.events.getWithSession(id); - }); + })); - ipcMain.handle("desktop:db:get-event-count-by-type", () => - agentDatabase.events.getCountByType(), + ipcMain.handle( + "desktop:db:get-event-count-by-type", + withDb((agentDatabase) => agentDatabase.events.getCountByType()), ); - ipcMain.handle("desktop:db:get-token-analytics", () => - agentDatabase.dashboard.getTokenAnalytics(), + ipcMain.handle( + "desktop:db:get-token-analytics", + withDb((agentDatabase) => agentDatabase.dashboard.getTokenAnalytics()), ); - ipcMain.handle("desktop:db:get-agent-hierarchy", (_event, sessionId: unknown) => { + ipcMain.handle("desktop:db:get-agent-hierarchy", withDb((agentDatabase, sessionId: unknown) => { const id = coerceDbId(sessionId); if (id === null) return []; return agentDatabase.agents.getBySessionWithChildren(id); - }); + })); - ipcMain.handle("desktop:db:get-analytics", () => - agentDatabase.dashboard.getAnalytics(), + ipcMain.handle( + "desktop:db:get-analytics", + withDb((agentDatabase) => agentDatabase.dashboard.getAnalytics()), ); - ipcMain.handle("desktop:db:get-workflow-data", () => - agentDatabase.dashboard.getWorkflowData(), + ipcMain.handle( + "desktop:db:get-workflow-data", + withDb((agentDatabase) => agentDatabase.dashboard.getWorkflowData()), ); - ipcMain.handle("desktop:db:get-core-features", () => - agentDatabase.dashboard.getCoreFeatures(), + ipcMain.handle( + "desktop:db:get-core-features", + withDb((agentDatabase) => agentDatabase.dashboard.getCoreFeatures()), ); - ipcMain.handle("desktop:db:get-packs", () => - agentDatabase.dashboard.getPacks(), + ipcMain.handle( + "desktop:db:get-packs", + withDb((agentDatabase) => agentDatabase.dashboard.getPacks()), ); - ipcMain.handle("desktop:db:get-skills", () => - agentDatabase.dashboard.getSkills(), + ipcMain.handle( + "desktop:db:get-skills", + withDb((agentDatabase) => agentDatabase.dashboard.getSkills()), ); - ipcMain.handle("desktop:db:get-tools", () => - agentDatabase.dashboard.getTools(), + ipcMain.handle( + "desktop:db:get-tools", + withDb((agentDatabase) => agentDatabase.dashboard.getTools()), ); - ipcMain.handle("desktop:db:get-subagents", () => - agentDatabase.dashboard.getSubAgents(), + ipcMain.handle( + "desktop:db:get-subagents", + withDb((agentDatabase) => agentDatabase.dashboard.getSubAgents()), ); - ipcMain.handle("desktop:db:get-plans", () => - agentDatabase.dashboard.getPlans(), + ipcMain.handle( + "desktop:db:get-plans", + withDb((agentDatabase) => agentDatabase.dashboard.getPlans()), ); - ipcMain.handle("desktop:db:get-pull-requests", () => - agentDatabase.dashboard.getPullRequests(), + ipcMain.handle( + "desktop:db:get-pull-requests", + withDb((agentDatabase) => agentDatabase.dashboard.getPullRequests()), ); // --- Catalog (FEA-1314) --- - const dbForStores = agentDatabase.storeDb; - - ipcMain.handle("desktop:db:get-catalog", () => - catalogStore.listCatalog(dbForStores), + ipcMain.handle( + "desktop:db:get-catalog", + withStoreDb((dbForStores) => catalogStore.listCatalog(dbForStores)), ); - ipcMain.handle("desktop:db:get-catalog-entry", (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-catalog-entry", withStoreDb((dbForStores, packId: unknown) => { if (typeof packId !== "string") return null; return catalogStore.getCatalog(dbForStores, packId); - }); + })); - ipcMain.handle("desktop:db:get-catalog-readme", async (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-catalog-readme", withStoreDb(async (dbForStores, packId: unknown) => { if (typeof packId !== "string") return null; const entry = await catalogStore.getCatalog(dbForStores, packId); return entry?.readme_excerpt ?? null; - }); + })); - ipcMain.handle("desktop:db:get-catalog-contents", async (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-catalog-contents", withStoreDb(async (dbForStores, packId: unknown) => { if (typeof packId !== "string") return null; const entry = await catalogStore.getCatalog(dbForStores, packId); if (!entry) return null; await refreshCatalogContents(dbForStores, entry); const refreshed = await catalogStore.getCatalog(dbForStores, packId); return refreshed?.contents_cache ?? null; - }); + })); - ipcMain.handle("desktop:db:get-catalog-history", (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-catalog-history", withStoreDb((dbForStores, packId: unknown) => { if (typeof packId !== "string") return []; return catalogStore.listHistory(dbForStores, packId); - }); + })); - ipcMain.handle("desktop:db:catalog-install", async (_event, packId: unknown, harness: unknown, cwd?: unknown) => { + ipcMain.handle("desktop:db:catalog-install", withStoreDb(async (dbForStores, packId: unknown, harness: unknown, cwd?: unknown) => { if (typeof packId !== "string" || typeof harness !== "string") return { started: false }; return streamRun(dbForStores, { pack_id: packId, @@ -398,9 +456,9 @@ function registerDesignSystemDbIpcHandlers( getWindow: options.getWindow, onComplete: () => void runPackScanner(dbForStores).catch(() => {}), }); - }); + })); - ipcMain.handle("desktop:db:catalog-uninstall", async (_event, packId: unknown, harness: unknown, cwd?: unknown) => { + ipcMain.handle("desktop:db:catalog-uninstall", withStoreDb(async (dbForStores, packId: unknown, harness: unknown, cwd?: unknown) => { if (typeof packId !== "string" || typeof harness !== "string") return { started: false }; return streamRun(dbForStores, { pack_id: packId, @@ -410,51 +468,57 @@ function registerDesignSystemDbIpcHandlers( getWindow: options.getWindow, onComplete: () => void runPackScanner(dbForStores).catch(() => {}), }); - }); + })); - ipcMain.handle("desktop:db:catalog-refresh", () => - runCatalogFetch(dbForStores), + ipcMain.handle( + "desktop:db:catalog-refresh", + withStoreDb((dbForStores) => runCatalogFetch(dbForStores)), ); - ipcMain.handle("desktop:db:get-install-runs", (_event, packId?: unknown) => - catalogStore.listInstallRuns(dbForStores, typeof packId === "string" ? { pack_id: packId } : {}), + ipcMain.handle( + "desktop:db:get-install-runs", + withStoreDb((dbForStores, packId?: unknown) => + catalogStore.listInstallRuns(dbForStores, typeof packId === "string" ? { pack_id: packId } : {}), + ), ); // --- Installed Packs (FEA-1224) --- - ipcMain.handle("desktop:db:get-installed-packs", () => - packStore.listPacks(dbForStores), + ipcMain.handle( + "desktop:db:get-installed-packs", + withStoreDb((dbForStores) => packStore.listPacks(dbForStores)), ); - ipcMain.handle("desktop:db:get-pack-detail", (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-pack-detail", withStoreDb((dbForStores, packId: unknown) => { if (typeof packId !== "string") return null; return packStore.getPack(dbForStores, packId); - }); + })); - ipcMain.handle("desktop:db:get-pack-sessions", (_event, packId: unknown) => { + ipcMain.handle("desktop:db:get-pack-sessions", withStoreDb((dbForStores, packId: unknown) => { if (typeof packId !== "string") return []; return packStore.listPackSessions(dbForStores, packId); - }); + })); - ipcMain.handle("desktop:db:get-all-skills", () => - packStore.listSkills(dbForStores), + ipcMain.handle( + "desktop:db:get-all-skills", + withStoreDb((dbForStores) => packStore.listSkills(dbForStores)), ); - ipcMain.handle("desktop:db:get-skill-invocations", (_event, name: unknown) => { + ipcMain.handle("desktop:db:get-skill-invocations", withStoreDb((dbForStores, name: unknown) => { if (typeof name !== "string") return []; return packStore.listSkillInvocations(dbForStores, name); - }); + })); - ipcMain.handle("desktop:db:get-recent-projects", async () => { + ipcMain.handle("desktop:db:get-recent-projects", withStoreDb(async (dbForStores) => { const result = await dbForStores.query<{ cwd: string }>( `SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY started_at DESC LIMIT 20`, ); return result.rows.map((r) => r.cwd); - }); + })); // --- Plans (FEA-1189) --- - ipcMain.handle("desktop:db:get-plans-list", (_event, opts?: unknown) => { + ipcMain.handle("desktop:db:get-plans-list", withStoreDb((dbForStores, opts?: unknown) => { const o = typeof opts === "object" && opts !== null ? opts as Record : {}; return planStore.listPlans(dbForStores, { sessionId: typeof o.sessionId === "string" ? o.sessionId : undefined, @@ -462,51 +526,52 @@ function registerDesignSystemDbIpcHandlers( limit: typeof o.limit === "number" ? o.limit : undefined, offset: typeof o.offset === "number" ? o.offset : undefined, }); - }); + })); - ipcMain.handle("desktop:db:get-plan", (_event, id: unknown) => { + ipcMain.handle("desktop:db:get-plan", withStoreDb((dbForStores, id: unknown) => { if (typeof id !== "string") return null; return planStore.getPlan(dbForStores, id); - }); + })); - ipcMain.handle("desktop:db:get-plan-versions", (_event, planId: unknown) => { + ipcMain.handle("desktop:db:get-plan-versions", withStoreDb((dbForStores, planId: unknown) => { if (typeof planId !== "string") return []; return planStore.getPlanVersions(dbForStores, planId); - }); + })); - ipcMain.handle("desktop:db:confirm-plan", (_event, id: unknown) => { + ipcMain.handle("desktop:db:confirm-plan", withStoreDb((dbForStores, id: unknown) => { if (typeof id !== "string") return; return planStore.confirmPlan(dbForStores, id); - }); + })); - ipcMain.handle("desktop:db:reject-plan", (_event, id: unknown) => { + ipcMain.handle("desktop:db:reject-plan", withStoreDb((dbForStores, id: unknown) => { if (typeof id !== "string") return; return planStore.rejectPlan(dbForStores, id); - }); + })); - ipcMain.handle("desktop:db:open-plan", async (_event, id: unknown, target?: unknown) => { + ipcMain.handle("desktop:db:open-plan", withStoreDb(async (dbForStores, id: unknown, target?: unknown) => { if (typeof id !== "string") return; const plan = await planStore.getPlan(dbForStores, id); if (!plan) return; const filePath = String(target === "log" ? plan.source_log_path : plan.file_path); if (filePath && filePath !== "null" && filePath !== "undefined") void shell.openPath(filePath); - }); + })); // --- Pull Requests (FEA-1226) --- - ipcMain.handle("desktop:db:get-pr-stats", () => - prStore.getPrStats(dbForStores), + ipcMain.handle( + "desktop:db:get-pr-stats", + withStoreDb((dbForStores) => prStore.getPrStats(dbForStores)), ); - ipcMain.handle("desktop:db:get-pr-sessions", (_event, opts?: unknown) => { + ipcMain.handle("desktop:db:get-pr-sessions", withStoreDb((dbForStores, opts?: unknown) => { const o = typeof opts === "object" && opts !== null ? opts as Record : {}; return prStore.listPrSessions(dbForStores, { limit: typeof o.limit === "number" ? o.limit : undefined, offset: typeof o.offset === "number" ? o.offset : undefined, }); - }); + })); - ipcMain.handle("desktop:db:get-pr-list", (_event, opts?: unknown) => { + ipcMain.handle("desktop:db:get-pr-list", withStoreDb((dbForStores, opts?: unknown) => { const o = typeof opts === "object" && opts !== null ? opts as Record : {}; return prStore.listPullRequests(dbForStores, { sessionId: typeof o.sessionId === "string" ? o.sessionId : undefined, @@ -514,15 +579,15 @@ function registerDesignSystemDbIpcHandlers( limit: typeof o.limit === "number" ? o.limit : undefined, offset: typeof o.offset === "number" ? o.offset : undefined, }); - }); + })); - ipcMain.handle("desktop:db:open-pr", async (_event, id: unknown) => { + ipcMain.handle("desktop:db:open-pr", withStoreDb(async (dbForStores, id: unknown) => { if (typeof id !== "string") return; const prs = await prStore.listPullRequests(dbForStores); const pr = prs.find((p) => p.id === id); const prUrl = pr?.pr_url; if (typeof prUrl === "string") void shell.openExternal(prUrl); - }); + })); } function unregisterDesignSystemDbIpcHandlers(): void { diff --git a/apps/desktop/src/main/packs/catalog-fetcher.ts b/apps/desktop/src/main/packs/catalog-fetcher.ts index 5e3602b1..b87d4578 100644 --- a/apps/desktop/src/main/packs/catalog-fetcher.ts +++ b/apps/desktop/src/main/packs/catalog-fetcher.ts @@ -23,6 +23,7 @@ import https from "node:https"; import type { Results } from "@electric-sql/pglite"; import { resolveBinaryFromLoginShellSync } from "../../server/shell-path.js"; +import { gatewayLog } from "../gateway-logger.js"; import { applyFetchResult } from "./catalog-store.js"; // FEA-1314 v6: marketplace sub-plugins (e.g. code-review, context7) live as @@ -300,8 +301,7 @@ export async function runCatalogFetch(db: CatalogDb): Promise { rows = result.rows; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[catalog-fetcher] cannot read pack_catalog:", msg); + gatewayLog.warn("catalog-fetcher", `cannot read pack_catalog: ${msg}`); return summary; } @@ -372,10 +372,9 @@ export async function runCatalogFetch(db: CatalogDb): Promise { summary.succeeded += 1; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn( - `[catalog-fetcher] applyFetchResult failed for ${row.pack_id}:`, - msg, + gatewayLog.warn( + "catalog-fetcher", + `applyFetchResult failed for ${row.pack_id}: ${msg}`, ); summary.failed += 1; } @@ -408,10 +407,9 @@ export async function runCatalogFetch(db: CatalogDb): Promise { summary.succeeded += 1; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn( - `[catalog-fetcher] applyFetchResult failed for ${row.pack_id}:`, - msg, + gatewayLog.warn( + "catalog-fetcher", + `applyFetchResult failed for ${row.pack_id}: ${msg}`, ); summary.failed += 1; } @@ -432,8 +430,7 @@ export function scheduleCatalogFetch( const handle = setInterval(() => { runCatalogFetch(db).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[catalog-fetcher] scheduled run failed:", msg); + gatewayLog.warn("catalog-fetcher", `scheduled run failed: ${msg}`); }); }, intervalMs); if (typeof handle.unref === "function") handle.unref(); diff --git a/apps/desktop/src/main/packs/pack-scanner.ts b/apps/desktop/src/main/packs/pack-scanner.ts index 9997af84..d6845fea 100644 --- a/apps/desktop/src/main/packs/pack-scanner.ts +++ b/apps/desktop/src/main/packs/pack-scanner.ts @@ -34,6 +34,11 @@ import { upsertSkill, upsertProjectAssociation, } from "./pack-store.js"; +import { gatewayLog } from "../gateway-logger.js"; +import { + resolveBinaryFromLoginShellSync, + type BinaryName, +} from "../../server/shell-path.js"; // --------------------------------------------------------------------------- // Types @@ -1006,7 +1011,7 @@ async function detectBinaryTool( db: PackScannerDb, opts: { pack_id: string; - binNames: string[]; + binNames: BinaryName[]; source_url: string | null; harnesses: string[]; versionArgs?: string[]; @@ -1014,18 +1019,10 @@ async function detectBinaryTool( ): Promise { let binaryPath: string | null = null; for (const bin of opts.binNames) { - try { - const out = execFileSync("/usr/bin/which", [bin], { - stdio: ["ignore", "pipe", "ignore"], - timeout: 1000, - }); - const trimmed = out.toString().trim(); - if (trimmed) { - binaryPath = trimmed; - break; - } - } catch { - /* not on PATH — try next bin name */ + const resolved = resolveBinaryFromLoginShellSync(bin); + if (resolved.source === "path") { + binaryPath = resolved.path; + break; } } if (!binaryPath) return false; @@ -1078,16 +1075,7 @@ async function detectClaudeCodeRouter(db: PackScannerDb): Promise { installed = true; } catch { // Fall back to probing the binary on PATH. - try { - // eslint-disable-next-line no-restricted-syntax - execFileSync("which", ["ccr"], { - stdio: ["ignore", "ignore", "ignore"], - timeout: 1000, - }); - installed = true; - } catch { - installed = false; - } + installed = resolveBinaryFromLoginShellSync("ccr").source === "path"; } if (!installed) return false; await upsertPack(db, { @@ -1131,8 +1119,7 @@ export async function runCatalogDetectorAdapters( results[name] = await fn(db); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn(`[catalog-detector] adapter ${name} failed:`, msg); + gatewayLog.warn("catalog-detector", `adapter ${name} failed: ${msg}`); results[name] = false; } } @@ -1163,8 +1150,7 @@ async function pruneStaleRows( ); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] tombstone agent_packs failed:", msg); + gatewayLog.warn("pack-scanner", `tombstone agent_packs failed: ${msg}`); } try { await db.query( @@ -1176,8 +1162,7 @@ async function pruneStaleRows( ); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] tombstone skills failed:", msg); + gatewayLog.warn("pack-scanner", `tombstone skills failed: ${msg}`); } try { await db.query( @@ -1186,10 +1171,9 @@ async function pruneStaleRows( ); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn( - "[pack-scanner] prune project_pack_associations failed:", - msg, + gatewayLog.warn( + "pack-scanner", + `prune project_pack_associations failed: ${msg}`, ); } } @@ -1243,24 +1227,21 @@ export async function runPackScanner( summary.scopes.gstack = true; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] gstack scan failed:", msg); + gatewayLog.warn("pack-scanner", `gstack scan failed: ${msg}`); } try { summary.bmad = await scanners.scanBmad(db); summary.scopes.bmad = true; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] bmad scan failed:", msg); + gatewayLog.warn("pack-scanner", `bmad scan failed: ${msg}`); } try { summary.marketplaces = await scanners.scanClaudeMarketplaces(db); summary.scopes.marketplaces = true; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] claude marketplace scan failed:", msg); + gatewayLog.warn("pack-scanner", `claude marketplace scan failed: ${msg}`); } try { summary.gstackProjects = @@ -1268,10 +1249,9 @@ export async function runPackScanner( summary.scopes.gstackProjects = true; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn( - "[pack-scanner] gstack project association scan failed:", - msg, + gatewayLog.warn( + "pack-scanner", + `gstack project association scan failed: ${msg}`, ); } try { @@ -1280,20 +1260,19 @@ export async function runPackScanner( summary.scopes.catalogDetectors = true; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - // eslint-disable-next-line no-console - console.warn("[pack-scanner] catalog detectors failed:", msg); + gatewayLog.warn("pack-scanner", `catalog detectors failed: ${msg}`); } const allSucceeded = Object.values(summary.scopes).every(Boolean); if (!allSucceeded) { summary.pruneSkipped = true; - // eslint-disable-next-line no-console - console.warn( - "[pack-scanner] skipping prune — some detector scopes failed:", - Object.entries(summary.scopes) - .filter(([, ok]) => !ok) - .map(([k]) => k) - .join(", "), + const failedScopes = Object.entries(summary.scopes) + .filter(([, ok]) => !ok) + .map(([k]) => k) + .join(", "); + gatewayLog.warn( + "pack-scanner", + `skipping prune - some detector scopes failed: ${failedScopes}`, ); } else { await pruneStaleRows(db, scanStartedAt); diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 6db838a0..cff4315c 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -213,7 +213,12 @@ export function getOverrideBinaryPaths(): BinaryPathOverrides | null { } export function getResolvedGitPath(): string { - return resolveBinaryFromLoginShellSync("git", getOverrideBinaryPaths()?.git).path; + const override = getOverrideBinaryPaths()?.git; + const resolved = resolveBinaryFromLoginShellSync("git", override); + if (resolved.source !== "override_invalid") { + return resolved.path; + } + return resolveBinaryFromLoginShellSync("git").path; } export function getResolvedGhPath(): string { @@ -1990,7 +1995,12 @@ async function removeWorktreeImpl( `git worktree remove failed for GENERATE_PRD, falling back to fs.rm`, ); } - await fs.rm(worktreeDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); try { execSync(`${shellEscape(gitBin)} worktree prune`, { cwd: expandedRepoPath, diff --git a/apps/desktop/src/server/shell-path.ts b/apps/desktop/src/server/shell-path.ts index 24f1029f..73d50a81 100644 --- a/apps/desktop/src/server/shell-path.ts +++ b/apps/desktop/src/server/shell-path.ts @@ -350,7 +350,15 @@ function resolveExecutablesOnPathSync( return hits; } -export type BinaryName = "claude" | "gh" | "codex" | "python3" | "git"; +export type BinaryName = + | "claude" + | "gh" + | "codex" + | "python3" + | "git" + | "rtk" + | "npm" + | "ccr"; export type BinaryResolveResult = { path: string; diff --git a/apps/desktop/test/agent-dashboard-boundary.test.ts b/apps/desktop/test/agent-dashboard-boundary.test.ts index 8f31c5a7..ed3a4afb 100644 --- a/apps/desktop/test/agent-dashboard-boundary.test.ts +++ b/apps/desktop/test/agent-dashboard-boundary.test.ts @@ -240,6 +240,19 @@ test("PGlite dashboard side effects stay behind the Agent Dashboard runtime boun assert.doesNotMatch(windowSource, /resolveLegacyRendererPath/); assert.doesNotMatch(windowSource, /loadFile\(rendererPath\)/); assert.match(designSystemRuntimeSource(), /ipcMain\.removeHandler\(channel\)/); + const designSystemSource = designSystemRuntimeSource(); + const handlerRegistrationIndex = designSystemSource.indexOf( + "registerIpcHandlers();", + ); + const databaseReadyIndex = designSystemSource.indexOf( + "const agentDatabase = await agentDatabasePromise;", + ); + assert.ok(handlerRegistrationIndex >= 0); + assert.ok(databaseReadyIndex >= 0); + assert.ok( + handlerRegistrationIndex < databaseReadyIndex, + "design-system DB IPC handlers must be registered before awaiting PGlite startup", + ); assert.match( appSource, /stopAgentCapture\(\{ closeDesignSystem: true \}\)/, diff --git a/apps/desktop/test/symphony-loop-binary-resolution.test.ts b/apps/desktop/test/symphony-loop-binary-resolution.test.ts index 6b29ef98..b79afeb6 100644 --- a/apps/desktop/test/symphony-loop-binary-resolution.test.ts +++ b/apps/desktop/test/symphony-loop-binary-resolution.test.ts @@ -102,6 +102,18 @@ describe("symphony-loop binary wrappers", () => { }); }); + test("getResolvedGitPath falls back when the configured override is invalid", () => { + const { paths, env } = setupFakeLoginShellBinaries(); + + withShellPathEnvForTest(env, () => { + configureBinaryPathsResolver(() => ({ + git: path.join(makeTempDir("symphony-loop-missing-git-"), "git"), + })); + + assert.equal(getResolvedGitPath(), paths.git); + }); + }); + test("getResolvedClaudePath matches the async resolver path", async () => { const { paths, env } = setupFakeLoginShellBinaries(); diff --git a/apps/desktop/test/symphony-loop-branch-materialization.test.ts b/apps/desktop/test/symphony-loop-branch-materialization.test.ts index 61d09ca3..6164c771 100644 --- a/apps/desktop/test/symphony-loop-branch-materialization.test.ts +++ b/apps/desktop/test/symphony-loop-branch-materialization.test.ts @@ -623,6 +623,53 @@ test("GENERATE_PRD records expected primary branch before PRD command starts", a assert.equal(payload.baseBranch, "main"); }); +test("fresh GENERATE_PRD removes stale deterministic directory before branch record", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "branch-prd-stale-dir-")); + tempPathsToClean.push(tmpDir); + await setupLoopRuntime(tmpDir); + const repo = await createRepoWithOrigin(tmpDir, "prd-stale-dir-repo"); + const api = await startBranchApi(); + const server = await startGateway(tmpDir, api.port); + + const artifactSlug = "PRD-604-stale-dir"; + const worktreeDir = path.join( + process.env.SYMPHONY_WORKTREE_PARENT_DIR!, + `${path.basename(repo.repoPath)}-loop-generate-prd-prd-604-stale-dir`, + ); + await fs.mkdir(path.join(worktreeDir, "node_modules", "pkg"), { + recursive: true, + }); + await fs.writeFile( + path.join(worktreeDir, "node_modules", "pkg", "stale.txt"), + "stale", + ); + + const loopId = "00000000-0000-0000-0000-000000113207"; + const branchName = "symphony/server-owned-prd-stale-dir-branch"; + const response = await postLoop(server, { + loopId, + command: LoopCommand.GeneratePrd, + closedLoopAuthToken: "loop-token", + artifacts: [], + prompt: "Generate the PRD", + artifactSlug, + repo: { fullName: repo.fullName, branch: "main" }, + branchMaterialization: branchMaterialization([ + { role: "primary", repositoryFullName: repo.fullName, branchName }, + ]), + }); + + assert.equal(response.status, 200, await response.text()); + await api.waitForRequest(`/loops/${loopId}/branch-artifact`); + assert.equal( + await assertRemoteBranch(repo.originPath, branchName), + await assertRemoteBranch(repo.originPath, "main"), + ); + await assert.rejects( + fs.access(path.join(worktreeDir, "node_modules", "pkg", "stale.txt")), + ); +}); + test("legacy PLAN payload without branch materialization uses legacy worktree setup", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "branch-missing-")); tempPathsToClean.push(tmpDir); From 193fa56bd64be19be4860a57ecf9be50ffae21b0 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:50:43 -0500 Subject: [PATCH 18/20] FEA-1550: Normalize pack catalog IPC contract - Map pack catalog SQL rows into the shared renderer CatalogEntry DTO before crossing IPC. - Update catalog contents, install runs, and install orchestration to consume the same camelCase catalog contract. - Guard Packs UI rendering against malformed catalog array fields so partial data cannot crash the screen. - Add focused catalog-store contract coverage for catalog, history, and install-run DTO mapping. Testing: Catalog-store contract test, desktop typecheck, desktop lint, and renderer production build passed. Risks: Low; catalog IPC now uses the documented shared DTO shape consistently. --- .../agent-dashboard-design-system-runtime.ts | 4 +- .../src/main/packs/catalog-contents.ts | 108 +++++++---- apps/desktop/src/main/packs/catalog-store.ts | 167 ++++++++++++++---- .../src/main/packs/install-orchestrator.ts | 32 +--- .../components/features/CatalogCard.tsx | 18 +- .../components/features/PacksCatalog.tsx | 48 +++-- .../test/catalog-store-contract.test.ts | 133 ++++++++++++++ 7 files changed, 395 insertions(+), 115 deletions(-) create mode 100644 apps/desktop/test/catalog-store-contract.test.ts diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index be61f75b..873ab1d2 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -429,7 +429,7 @@ function registerDesignSystemDbIpcHandlers( ipcMain.handle("desktop:db:get-catalog-readme", withStoreDb(async (dbForStores, packId: unknown) => { if (typeof packId !== "string") return null; const entry = await catalogStore.getCatalog(dbForStores, packId); - return entry?.readme_excerpt ?? null; + return entry?.readmeExcerpt ?? null; })); ipcMain.handle("desktop:db:get-catalog-contents", withStoreDb(async (dbForStores, packId: unknown) => { @@ -438,7 +438,7 @@ function registerDesignSystemDbIpcHandlers( if (!entry) return null; await refreshCatalogContents(dbForStores, entry); const refreshed = await catalogStore.getCatalog(dbForStores, packId); - return refreshed?.contents_cache ?? null; + return refreshed?.contentsCache ?? null; })); ipcMain.handle("desktop:db:get-catalog-history", withStoreDb((dbForStores, packId: unknown) => { diff --git a/apps/desktop/src/main/packs/catalog-contents.ts b/apps/desktop/src/main/packs/catalog-contents.ts index 4d86bd93..119e247c 100644 --- a/apps/desktop/src/main/packs/catalog-contents.ts +++ b/apps/desktop/src/main/packs/catalog-contents.ts @@ -17,7 +17,7 @@ * (claude-plugins-official entries) * - none — pack has no skill/command listing (RTK, claude-code-router) * - * Returns [{ name, kind, description?, path? }]. kind is one of + * Returns [{ name, type, description?, path? }]. type is one of * 'skill', 'command', 'agent', 'plugin'. */ @@ -44,7 +44,7 @@ export type ContentItemKind = "skill" | "command" | "agent" | "plugin"; export interface ContentItem { name: string; - kind: ContentItemKind; + type: ContentItemKind; description?: string | null; path?: string; category?: string; @@ -78,10 +78,10 @@ interface ContentsSpec { } export interface CatalogEntry { - pack_id: string; - github_url: string; - contents: ContentsSpec | null; - contents_fetched_at?: string | null; + packId: string; + githubUrl: string; + contents: Record | null; + contentsFetchedAt?: string | null; } interface GitHubTreeEntry { @@ -107,6 +107,31 @@ type CatalogDb = DbClient; // ---------- low-level GitHub helpers ---------- +function readString( + value: Record, + key: keyof ContentsSpec, +): string | undefined { + const raw = value[key]; + return typeof raw === "string" ? raw : undefined; +} + +function readStringArray( + value: Record, + key: keyof ContentsSpec, +): string[] { + const raw = value[key]; + return Array.isArray(raw) + ? raw.filter((item): item is string => typeof item === "string") + : []; +} + +function readKind(value: Record): ContentItemKind | undefined { + const raw = value.kind; + return raw === "skill" || raw === "command" || raw === "agent" || raw === "plugin" + ? raw + : undefined; +} + function parseGithubUrl(url: string | null | undefined): ParsedRepo | null { const m = String(url || "").match(/github\.com[/:]([^/]+)\/([^/?#.]+)/); return m ? { owner: m[1], repo: m[2].replace(/\.git$/, "") } : null; @@ -226,13 +251,13 @@ async function fetchSkillTree( `repos/${owner}/${repo}/contents/${encodeURI(entry.path)}/${skillMarker}`, ); if (!file || !file.content) { - skills.push({ name: entry.name, kind: "skill", path: entry.path }); + skills.push({ name: entry.name, type: "skill", path: entry.path }); continue; } const meta = parseSkillFrontmatterFromBase64(file.content); skills.push({ name: meta.name || entry.name, - kind: "skill", + type: "skill", description: meta.description || null, path: entry.path, }); @@ -259,7 +284,7 @@ async function fetchFlatMd( ) .map((e) => ({ name: e.name.replace(/\.md$/, ""), - kind: kind || "command", + type: kind || "command", path: e.path, })); } @@ -288,7 +313,7 @@ async function fetchNestedMd( if (file.name.toLowerCase().startsWith("readme")) continue; items.push({ name: file.name.replace(/\.md$/, ""), - kind: kind || "agent", + type: kind || "agent", category: cat.name, path: file.path, }); @@ -327,7 +352,7 @@ async function fetchClaudeMarketplace( if (Array.isArray(parsed.plugins)) { const items: ContentItem[] = parsed.plugins.map((p) => ({ name: p.name, - kind: "plugin" as const, + type: "plugin" as const, description: p.description || null, })); // If a plugins_root is declared, walk each plugin's skills/ dir for a @@ -416,7 +441,7 @@ async function fetchNestedSkillTree( if (skillDir.type !== "dir") continue; items.push({ name: skillDir.name, - kind: "skill", + type: "skill", category: teamDir.name, path: skillDir.path, }); @@ -431,37 +456,54 @@ async function fetchNestedSkillTree( export async function fetchContents(entry: CatalogEntry): Promise { const contents = entry.contents; - if (!contents || !contents.type) return []; - const parsed = parseGithubUrl(entry.github_url); + const type = contents ? readString(contents, "type") : undefined; + if (!contents || !type) return []; + const parsed = parseGithubUrl(entry.githubUrl); if (!parsed) return []; const { owner, repo } = parsed; - switch (contents.type) { - case "github-skill-tree": - return fetchSkillTree(owner, repo, contents.skills_path!, contents.skill_marker); - case "github-multi-skill-tree": - return fetchMultiSkillTree(owner, repo, contents.skill_paths, contents.skill_marker); - case "github-flat-md": - return fetchFlatMd(owner, repo, contents.md_path!, contents.kind); - case "github-nested-md": - return fetchNestedMd(owner, repo, contents.root_path!, contents.kind); + switch (type) { + case "github-skill-tree": { + const skillsPath = readString(contents, "skills_path"); + if (!skillsPath) return []; + return fetchSkillTree(owner, repo, skillsPath, readString(contents, "skill_marker")); + } + case "github-multi-skill-tree": { + const skillPaths = readStringArray(contents, "skill_paths"); + if (skillPaths.length === 0) return []; + return fetchMultiSkillTree(owner, repo, skillPaths, readString(contents, "skill_marker")); + } + case "github-flat-md": { + const mdPath = readString(contents, "md_path"); + if (!mdPath) return []; + return fetchFlatMd(owner, repo, mdPath, readKind(contents)); + } + case "github-nested-md": { + const rootPath = readString(contents, "root_path"); + if (!rootPath) return []; + return fetchNestedMd(owner, repo, rootPath, readKind(contents)); + } case "github-nested-skill-tree": - return fetchNestedSkillTree(owner, repo, contents.match_pattern); + return fetchNestedSkillTree(owner, repo, readString(contents, "match_pattern")); case "claude-marketplace": { - const repoFromContents = contents.marketplace_repo - ? parseGithubUrl(`https://github.com/${contents.marketplace_repo}`) + const marketplaceRepo = readString(contents, "marketplace_repo"); + const repoFromContents = marketplaceRepo + ? parseGithubUrl(`https://github.com/${marketplaceRepo}`) : null; const mkO = repoFromContents ? repoFromContents.owner : owner; const mkR = repoFromContents ? repoFromContents.repo : repo; - return fetchClaudeMarketplace(mkO, mkR, contents.plugins_root); + return fetchClaudeMarketplace(mkO, mkR, readString(contents, "plugins_root")); } case "github-claude-plugin": { - const repoFromContents = contents.marketplace_repo - ? parseGithubUrl(`https://github.com/${contents.marketplace_repo}`) + const marketplaceRepo = readString(contents, "marketplace_repo"); + const repoFromContents = marketplaceRepo + ? parseGithubUrl(`https://github.com/${marketplaceRepo}`) : null; const pluginO = repoFromContents ? repoFromContents.owner : owner; const pluginR = repoFromContents ? repoFromContents.repo : repo; - return fetchClaudePlugin(pluginO, pluginR, contents.plugin_path!); + const pluginPath = readString(contents, "plugin_path"); + if (!pluginPath) return []; + return fetchClaudePlugin(pluginO, pluginR, pluginPath); } case "none": return []; @@ -479,13 +521,13 @@ export async function refreshCatalogContents( catalogEntry: CatalogEntry, ): Promise { const items = await fetchContents(catalogEntry); - await applyContentsFetch(db, { pack_id: catalogEntry.pack_id, items }); + await applyContentsFetch(db, { pack_id: catalogEntry.packId, items }); return items; } export function isContentsFresh(entry: CatalogEntry): boolean { - if (!entry.contents_fetched_at) return false; + if (!entry.contentsFetchedAt) return false; return ( - Date.now() - new Date(entry.contents_fetched_at).getTime() < CONTENTS_TTL_MS + Date.now() - new Date(entry.contentsFetchedAt).getTime() < CONTENTS_TTL_MS ); } diff --git a/apps/desktop/src/main/packs/catalog-store.ts b/apps/desktop/src/main/packs/catalog-store.ts index 84d6b10d..ab4bfd70 100644 --- a/apps/desktop/src/main/packs/catalog-store.ts +++ b/apps/desktop/src/main/packs/catalog-store.ts @@ -17,6 +17,12 @@ */ import type { Results } from "@electric-sql/pglite"; +import type { + CatalogContentItem, + CatalogContentsConfig, + CatalogEntry, + InstallRunRecord, +} from "../../shared/agent-db-contract.js"; /** Minimal subset of PgliteClient / PgliteExecutor used by catalog-store. */ type DbClient = { @@ -132,23 +138,6 @@ interface SeedDoc { packs: SeedPack[]; } -// --------------------------------------------------------------------------- -// Hydration — PGlite JSONB columns return parsed objects, so no JSON.parse. -// Provide safe fallback defaults for nullable JSONB fields. -// --------------------------------------------------------------------------- - -function hydrateRow(row: CatalogRow) { - return { - ...row, - harnesses: row.harnesses ?? [], - install_commands: row.install_commands ?? {}, - uninstall_commands: row.uninstall_commands ?? {}, - contents: row.contents ?? null, - contents_cache: row.contents_cache ?? null, - post_install: row.post_install ?? null, - }; -} - // --------------------------------------------------------------------------- // Usage attribution (best-effort) // --------------------------------------------------------------------------- @@ -169,6 +158,116 @@ async function loadUsageMap( return new Map(); } +function stringRecordOrNull( + value: Record | null, +): Record | null { + if (!value) { + return null; + } + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + return Object.fromEntries(entries); +} + +function catalogContentsOrNull( + value: Record | null, +): CatalogContentsConfig | null { + if (!value || typeof value.type !== "string") { + return null; + } + return { ...value, type: value.type }; +} + +function isCatalogContentItem(value: unknown): value is CatalogContentItem { + if (typeof value !== "object" || value === null) { + return false; + } + const item = value as { name?: unknown; type?: unknown }; + return typeof item.name === "string" && typeof item.type === "string"; +} + +function catalogContentItemsOrNull(value: unknown[] | null): CatalogContentItem[] | null { + if (!Array.isArray(value)) { + return null; + } + return value.filter(isCatalogContentItem); +} + +function splitInstalledHarnesses(value: string | null): string[] { + if (!value) { + return []; + } + return value.split(",").filter(Boolean); +} + +function toHistoryEntry(row: HistoryRow): CatalogEntry["history"][number] { + return { + fetchedAt: row.fetched_at, + stars: row.stars ?? 0, + forks: row.forks ?? 0, + }; +} + +function toCatalogEntry( + row: CatalogRow, + { + history = [], + usage = null, + }: { + history?: CatalogEntry["history"]; + usage?: PackUsage | null; + } = {}, +): CatalogEntry { + return { + packId: row.pack_id, + displayName: row.display_name, + category: row.category, + githubUrl: row.github_url, + marketplaceUrl: row.marketplace_url, + description: row.description, + descriptionLive: row.description_live, + harnesses: row.harnesses ?? [], + installCommands: stringRecordOrNull(row.install_commands), + uninstallCommands: stringRecordOrNull(row.uninstall_commands), + installNotes: row.install_notes, + placeholderReason: row.placeholder_reason, + verified: row.verified, + readmeExcerpt: row.readme_excerpt, + stars: row.stars, + forks: row.forks, + lastRelease: row.last_release, + seedVersion: row.seed_version, + pinOrder: row.pin_order, + contents: catalogContentsOrNull(row.contents), + contentsCache: catalogContentItemsOrNull(row.contents_cache), + detectionPatterns: row.detection_patterns, + harnessAgnostic: row.harness_agnostic, + projectScoped: row.project_scoped, + singleInstall: row.single_install, + postInstall: row.post_install, + installedHarnesses: splitInstalledHarnesses(row.installed_harnesses), + skillCount: row.installed_skill_count ?? 0, + usageCount: usage?.tool_calls ?? 0, + history, + }; +} + +function toInstallRunRecord(row: InstallRunRow): InstallRunRecord { + return { + id: row.id, + packId: row.pack_id, + harness: row.harness, + action: row.action, + command: row.command, + exitCode: row.exit_code, + startedAt: row.started_at, + endedAt: row.ended_at, + stdoutTail: row.stdout_tail, + stderrTail: row.stderr_tail, + }; +} + // --------------------------------------------------------------------------- // Seed upsert // --------------------------------------------------------------------------- @@ -287,7 +386,7 @@ export async function upsertCatalogSeed( * Installed status is decorated via subquery joins against `agent_packs` and * `skills`. */ -export async function listCatalog(db: DbClient) { +export async function listCatalog(db: DbClient): Promise { const result = await db.query( `SELECT c.*, @@ -313,13 +412,9 @@ export async function listCatalog(db: DbClient) { const usage = await loadUsageMap(db); - return rows.map((r) => ({ - ...hydrateRow(r), - installed_harnesses: r.installed_harnesses - ? r.installed_harnesses.split(",") - : [], - usage: usage.get(r.pack_id) || null, - })); + return rows.map((row) => + toCatalogEntry(row, { usage: usage.get(row.pack_id) ?? null }), + ); } // --------------------------------------------------------------------------- @@ -331,7 +426,7 @@ export async function getCatalog( db: DbClient, packId: string, { historyDays = 30 }: { historyDays?: number } = {}, -) { +): Promise { const result = await db.query( `SELECT c.*, @@ -355,14 +450,10 @@ export async function getCatalog( const usage = await loadUsageMap(db); - return { - ...hydrateRow(row), - installed_harnesses: row.installed_harnesses - ? row.installed_harnesses.split(",") - : [], - usage: usage.get(packId) || null, + return toCatalogEntry(row, { + usage: usage.get(packId) ?? null, history: await listHistory(db, packId, historyDays), - }; + }); } // --------------------------------------------------------------------------- @@ -373,7 +464,7 @@ export async function listHistory( db: DbClient, packId: string, days = 30, -): Promise { +): Promise { const since = new Date( Date.now() - days * 24 * 60 * 60 * 1000, ).toISOString(); @@ -384,7 +475,7 @@ export async function listHistory( ORDER BY fetched_at ASC`, [packId, since], ); - return result.rows; + return result.rows.map(toHistoryEntry); } // --------------------------------------------------------------------------- @@ -575,7 +666,7 @@ export async function listInstallRuns( limit = 50, offset = 0, }: { pack_id?: string | null; limit?: number; offset?: number } = {}, -): Promise { +): Promise { if (pack_id) { const result = await db.query( `SELECT * FROM pack_install_runs @@ -584,7 +675,7 @@ export async function listInstallRuns( LIMIT $2 OFFSET $3`, [pack_id, limit, offset], ); - return result.rows; + return result.rows.map(toInstallRunRecord); } const result = await db.query( `SELECT * FROM pack_install_runs @@ -592,7 +683,7 @@ export async function listInstallRuns( LIMIT $1 OFFSET $2`, [limit, offset], ); - return result.rows; + return result.rows.map(toInstallRunRecord); } export async function deleteInstallRun( diff --git a/apps/desktop/src/main/packs/install-orchestrator.ts b/apps/desktop/src/main/packs/install-orchestrator.ts index 22eea140..25a832ec 100644 --- a/apps/desktop/src/main/packs/install-orchestrator.ts +++ b/apps/desktop/src/main/packs/install-orchestrator.ts @@ -23,6 +23,7 @@ import { homedir } from "node:os"; import path from "node:path"; import type { BrowserWindow } from "electron"; import type { Results } from "@electric-sql/pglite"; +import type { CatalogEntry } from "../../shared/agent-db-contract.js"; import { gatewayLog } from "../gateway-logger.js"; import { getCatalog, @@ -86,17 +87,6 @@ interface TrustedActionResult { error?: { code: string; message: string }; } -interface CatalogEntry { - pack_id: string; - single_install?: number; - project_scoped?: boolean | number; - harnesses?: string[]; - install_commands?: Record; - uninstall_commands?: Record; - post_install?: unknown; - [key: string]: unknown; -} - // --------------------------------------------------------------------------- // ANSI stripping + tail helpers // --------------------------------------------------------------------------- @@ -295,7 +285,7 @@ export function pickSingleInstallCommand( action: "install" | "uninstall", ): { command: string | null; registerHarnesses: string[] } { const cmdMap = - action === "uninstall" ? entry.uninstall_commands : entry.install_commands; + action === "uninstall" ? entry.uninstallCommands : entry.installCommands; const harnesses = Array.isArray(entry.harnesses) ? entry.harnesses : []; if (action === "uninstall") { @@ -426,7 +416,7 @@ export async function streamRun(db: DbClient, opts: StreamRunOptions): Promise 0; + const harnesses = catalogHarnesses(entry); + const installedHarnesses = catalogInstalledHarnesses(entry); + const isInstalled = installedHarnesses.length > 0; const starHistory = entry.history?.map((h) => h.stars) ?? []; return ( @@ -84,7 +86,7 @@ export function CatalogCard({ {/* Harness badges */}
- {entry.harnesses.map((h) => ( + {harnesses.map((h) => ( {h} ))}
@@ -95,8 +97,8 @@ export function CatalogCard({ className="mt-3 flex flex-wrap gap-2 border-t border-[var(--border)] pt-3" onClick={(e) => e.stopPropagation()} > - {entry.harnesses.map((harness) => { - const installed = entry.installedHarnesses.includes(harness); + {harnesses.map((harness) => { + const installed = installedHarnesses.includes(harness); const busy = installing?.[`${entry.packId}:${harness}`] ?? false; return installed ? ( @@ -131,6 +133,14 @@ export function CatalogCard({ ); } +function catalogHarnesses(entry: CatalogEntry): string[] { + return Array.isArray(entry.harnesses) ? entry.harnesses : []; +} + +function catalogInstalledHarnesses(entry: CatalogEntry): string[] { + return Array.isArray(entry.installedHarnesses) ? entry.installedHarnesses : []; +} + function formatCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; diff --git a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx index a30c4966..1aef4906 100644 --- a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx +++ b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx @@ -118,31 +118,31 @@ export function PacksCatalog() { const lowerSearch = search.toLowerCase(); const filteredCatalog = useMemo(() => { - if (!catalog) return []; - if (!lowerSearch) return catalog; - return catalog.filter( + const entries = Array.isArray(catalog) ? catalog : []; + if (!lowerSearch) return entries; + return entries.filter( (e) => - e.displayName.toLowerCase().includes(lowerSearch) || + catalogDisplayName(e).toLowerCase().includes(lowerSearch) || e.description?.toLowerCase().includes(lowerSearch) || e.category?.toLowerCase().includes(lowerSearch) || - e.packId.toLowerCase().includes(lowerSearch), + catalogPackId(e).toLowerCase().includes(lowerSearch), ); }, [catalog, lowerSearch]); const installedEntries = useMemo( - () => filteredCatalog.filter((e) => e.installedHarnesses.length > 0), + () => filteredCatalog.filter((e) => catalogInstalledHarnesses(e).length > 0), [filteredCatalog], ); const discoverEntries = useMemo( - () => filteredCatalog.filter((e) => e.installedHarnesses.length === 0), + () => filteredCatalog.filter((e) => catalogInstalledHarnesses(e).length === 0), [filteredCatalog], ); const hasProjectScopedActions = useMemo( () => filteredCatalog.some((entry) => - entry.harnesses.some( + catalogHarnesses(entry).some( (harness) => requiresProjectCwd(entry, harness, "install") || requiresProjectCwd(entry, harness, "uninstall"), @@ -474,11 +474,13 @@ function PackDetailView({ const displayName = catalogEntry?.displayName ?? packId; const description = catalogEntry?.descriptionLive ?? catalogEntry?.description; const starHistory = catalogEntry?.history?.map((h) => h.stars) ?? []; - const hasProjectScopedActions = catalogEntry?.harnesses.some( - (harness) => - requiresProjectCwd(catalogEntry, harness, "install") || - requiresProjectCwd(catalogEntry, harness, "uninstall"), - ) ?? false; + const hasProjectScopedActions = catalogEntry + ? catalogHarnesses(catalogEntry).some( + (harness) => + requiresProjectCwd(catalogEntry, harness, "install") || + requiresProjectCwd(catalogEntry, harness, "uninstall"), + ) + : false; return ( @@ -541,8 +543,8 @@ function PackDetailView({ {catalogEntry && (
- {catalogEntry.harnesses.map((harness) => { - const installed = catalogEntry.installedHarnesses.includes(harness); + {catalogHarnesses(catalogEntry).map((harness) => { + const installed = catalogInstalledHarnesses(catalogEntry).includes(harness); const busy = installing[`${packId}:${harness}`] ?? false; return ( @@ -725,6 +727,22 @@ function requiresProjectCwd( ); } +function catalogHarnesses(entry: CatalogEntry): string[] { + return Array.isArray(entry.harnesses) ? entry.harnesses : []; +} + +function catalogInstalledHarnesses(entry: CatalogEntry): string[] { + return Array.isArray(entry.installedHarnesses) ? entry.installedHarnesses : []; +} + +function catalogDisplayName(entry: CatalogEntry): string { + return typeof entry.displayName === "string" ? entry.displayName : catalogPackId(entry); +} + +function catalogPackId(entry: CatalogEntry): string { + return typeof entry.packId === "string" ? entry.packId : ""; +} + function resolveProjectCwdForAction( entry: CatalogEntry | null | undefined, harness: string, diff --git a/apps/desktop/test/catalog-store-contract.test.ts b/apps/desktop/test/catalog-store-contract.test.ts new file mode 100644 index 00000000..54fa9e57 --- /dev/null +++ b/apps/desktop/test/catalog-store-contract.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + getCatalog, + listCatalog, + listHistory, + listInstallRuns, +} from "../src/main/packs/catalog-store.js"; + +type QueryResult> = { rows: T[] }; + +type StubDb = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +function createStubDb(): StubDb { + return { + async query>( + sql: string, + params?: unknown[], + ): Promise> { + if (sql.includes("FROM pack_catalog_history")) { + return { + rows: [ + { + fetched_at: "2026-06-08T12:00:00.000Z", + stars: 12, + forks: 3, + }, + ] as T[], + }; + } + if (sql.includes("FROM pack_install_runs")) { + return { + rows: [ + { + id: 7, + pack_id: params?.[0] ?? "demo-pack", + harness: "claude", + action: "install", + command: "echo install", + exit_code: 0, + started_at: "2026-06-08T12:00:00.000Z", + ended_at: "2026-06-08T12:01:00.000Z", + stdout_tail: "ok", + stderr_tail: null, + }, + ] as T[], + }; + } + return { + rows: [ + { + pack_id: "demo-pack", + display_name: "Demo Pack", + category: "tools", + github_url: "https://github.com/acme/demo-pack", + marketplace_url: null, + description: "Seed description", + description_live: "Live description", + harnesses: ["claude", "codex"], + install_commands: { claude: "echo install" }, + uninstall_commands: { claude: "echo uninstall" }, + install_notes: "notes", + placeholder_reason: null, + verified: true, + readme_excerpt: "readme", + readme_fetched_at: null, + stars: 12, + forks: 3, + last_release: "v1.0.0", + last_fetched_at: null, + seed_version: 4, + pin_order: 1, + contents: { type: "none" }, + contents_cache: [{ name: "demo", type: "skill" }], + contents_fetched_at: null, + detection_patterns: ["demo"], + harness_agnostic: false, + project_scoped: true, + single_install: false, + post_install: { message: "done" }, + installed_harnesses: "claude", + installed_skill_count: 2, + uninstalled_at: null, + }, + ] as T[], + }; + }, + }; +} + +describe("catalog-store renderer contract", () => { + test("listCatalog maps raw DB rows into CatalogEntry DTO fields", async () => { + const entries = await listCatalog(createStubDb()); + + assert.equal(entries.length, 1); + assert.equal(entries[0]?.packId, "demo-pack"); + assert.equal(entries[0]?.displayName, "Demo Pack"); + assert.deepEqual(entries[0]?.harnesses, ["claude", "codex"]); + assert.deepEqual(entries[0]?.installedHarnesses, ["claude"]); + assert.equal(entries[0]?.skillCount, 2); + assert.equal(entries[0]?.usageCount, 0); + assert.equal(entries[0]?.projectScoped, true); + assert.equal(entries[0]?.installCommands?.claude, "echo install"); + assert.equal(entries[0]?.uninstallCommands?.claude, "echo uninstall"); + assert.equal(entries[0]?.contents?.type, "none"); + assert.equal(entries[0]?.contentsCache?.[0]?.name, "demo"); + assert.deepEqual(entries[0]?.history, []); + }); + + test("getCatalog and listHistory return camelCase history fields", async () => { + const entry = await getCatalog(createStubDb(), "demo-pack"); + const history = await listHistory(createStubDb(), "demo-pack"); + + assert.equal(entry?.history[0]?.fetchedAt, "2026-06-08T12:00:00.000Z"); + assert.equal(entry?.history[0]?.stars, 12); + assert.deepEqual(history, entry?.history); + }); + + test("listInstallRuns maps raw DB rows into InstallRunRecord DTO fields", async () => { + const runs = await listInstallRuns(createStubDb(), { pack_id: "demo-pack" }); + + assert.equal(runs.length, 1); + assert.equal(runs[0]?.packId, "demo-pack"); + assert.equal(runs[0]?.exitCode, 0); + assert.equal(runs[0]?.startedAt, "2026-06-08T12:00:00.000Z"); + assert.equal(runs[0]?.stdoutTail, "ok"); + }); +}); From 99f902f7908243c21c8bb0e09cdac23393ac8288 Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 08:55:52 -0500 Subject: [PATCH 19/20] FEA-1550: Fix recent projects query for PGlite - Replace the Packs recent-projects DISTINCT query with a Postgres-compatible GROUP BY query. - Filter blank cwd values and preserve most-recent ordering by MAX(started_at). - Add a boundary guard for the invalid DISTINCT/ORDER BY pattern. Testing: Agent dashboard boundary test, desktop typecheck, and desktop lint passed. Risks: Low; only affects the recent project picker query used by pack actions. --- .../src/main/agent-dashboard-design-system-runtime.ts | 7 ++++++- apps/desktop/test/agent-dashboard-boundary.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 873ab1d2..599b79a8 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -511,7 +511,12 @@ function registerDesignSystemDbIpcHandlers( ipcMain.handle("desktop:db:get-recent-projects", withStoreDb(async (dbForStores) => { const result = await dbForStores.query<{ cwd: string }>( - `SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY started_at DESC LIMIT 20`, + `SELECT cwd + FROM sessions + WHERE cwd IS NOT NULL AND cwd != '' + GROUP BY cwd + ORDER BY MAX(started_at) DESC NULLS LAST + LIMIT 20`, ); return result.rows.map((r) => r.cwd); })); diff --git a/apps/desktop/test/agent-dashboard-boundary.test.ts b/apps/desktop/test/agent-dashboard-boundary.test.ts index ed3a4afb..9d63a6f9 100644 --- a/apps/desktop/test/agent-dashboard-boundary.test.ts +++ b/apps/desktop/test/agent-dashboard-boundary.test.ts @@ -253,6 +253,15 @@ test("PGlite dashboard side effects stay behind the Agent Dashboard runtime boun handlerRegistrationIndex < databaseReadyIndex, "design-system DB IPC handlers must be registered before awaiting PGlite startup", ); + assert.doesNotMatch( + designSystemSource, + /SELECT DISTINCT cwd[\s\S]*ORDER BY started_at/, + "recent-projects query must stay valid for Postgres/PGlite", + ); + assert.match( + designSystemSource, + /GROUP BY cwd[\s\S]*ORDER BY MAX\(started_at\) DESC NULLS LAST/, + ); assert.match( appSource, /stopAgentCapture\(\{ closeDesignSystem: true \}\)/, From 39c121c2afd058a984285720ceaa738fbf82492d Mon Sep 17 00:00:00 2001 From: mikeangstadt Date: Mon, 8 Jun 2026 09:08:35 -0500 Subject: [PATCH 20/20] FEA-1550: Normalize ported screen data contracts - Map installed packs, skills, plans, and pull requests into shared renderer DTOs before IPC. - Add renderer array guards for ported screens so stale or malformed arrays cannot crash map/length usage. - Add ported-screen store contract coverage for the IPC-facing DTO shapes. Testing: Full desktop test suite passed; focused ported-screen store contract tests, catalog contract tests, dashboard boundary tests, desktop typecheck, desktop lint, and renderer production build passed. Risks: Low; ported screen IPC now returns documented shared DTO shapes and renderer guards tolerate empty or malformed arrays. --- .../agent-dashboard-design-system-runtime.ts | 4 +- apps/desktop/src/main/packs/pack-store.ts | 127 ++++++++-- apps/desktop/src/main/plans/plan-store.ts | 118 ++++++++- .../src/main/pull-requests/pr-store.ts | 80 +++--- .../components/features/CatalogCard.tsx | 6 +- .../components/features/CoreFeaturesView.tsx | 10 +- .../components/features/PacksCatalog.tsx | 80 ++++-- .../components/features/PlansView.tsx | 15 +- .../components/features/PullRequestsView.tsx | 23 +- .../test/ported-screen-store-contract.test.ts | 238 ++++++++++++++++++ 10 files changed, 598 insertions(+), 103 deletions(-) create mode 100644 apps/desktop/test/ported-screen-store-contract.test.ts diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 599b79a8..2d6869a8 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -557,7 +557,7 @@ function registerDesignSystemDbIpcHandlers( if (typeof id !== "string") return; const plan = await planStore.getPlan(dbForStores, id); if (!plan) return; - const filePath = String(target === "log" ? plan.source_log_path : plan.file_path); + const filePath = String(target === "log" ? plan.sourceLogPath : plan.filePath); if (filePath && filePath !== "null" && filePath !== "undefined") void shell.openPath(filePath); })); @@ -590,7 +590,7 @@ function registerDesignSystemDbIpcHandlers( if (typeof id !== "string") return; const prs = await prStore.listPullRequests(dbForStores); const pr = prs.find((p) => p.id === id); - const prUrl = pr?.pr_url; + const prUrl = pr?.prUrl; if (typeof prUrl === "string") void shell.openExternal(prUrl); })); } diff --git a/apps/desktop/src/main/packs/pack-store.ts b/apps/desktop/src/main/packs/pack-store.ts index a87c41de..0316ae8a 100644 --- a/apps/desktop/src/main/packs/pack-store.ts +++ b/apps/desktop/src/main/packs/pack-store.ts @@ -16,6 +16,12 @@ */ import type { Results } from "@electric-sql/pglite"; +import type { + InstalledPack, + InstalledPackDetail, + SkillInvocation, + SkillWithInvocations, +} from "../../shared/agent-db-contract.js"; type DbClient = { query>( @@ -149,6 +155,93 @@ interface PackListRow extends Record { skill_count: number; } +function splitHarnesses(value: string | null): string[] { + if (!value) { + return []; + } + return value.split(",").filter(Boolean); +} + +function toInstalledPack(row: PackListRow): InstalledPack { + return { + packId: row.pack_id, + harnesses: splitHarnesses(row.harnesses), + installs: [], + skillCount: row.skill_count, + lastSeenAt: row.last_seen_at, + }; +} + +function toInstalledPackInstall(row: PackInstallRow): InstalledPack["installs"][number] { + return { + harness: row.harness, + installPath: row.install_path, + installKind: row.install_kind, + sourceUrl: row.source_url, + version: row.version, + detectedAt: row.detected_at, + lastSeenAt: row.last_seen_at, + }; +} + +function toInstalledPackDetail( + packId: string, + installs: PackInstallRow[], + skills: SkillRow[], + associations: ProjectAssociationRow[], +): InstalledPackDetail { + const sortedLastSeenTimes = installs + .map((install) => install.last_seen_at) + .filter((value): value is string => typeof value === "string") + .sort(); + const lastSeenAt = sortedLastSeenTimes.length > 0 + ? sortedLastSeenTimes[sortedLastSeenTimes.length - 1]! + : null; + + return { + packId, + harnesses: [...new Set(installs.map((install) => install.harness))], + installs: installs.map(toInstalledPackInstall), + skillCount: skills.length, + lastSeenAt, + skills: skills.map((skill) => ({ + skillId: skill.skill_id, + name: skill.name, + version: skill.version, + description: skill.description, + harness: skill.harness, + })), + associations: associations.map((association) => ({ + projectPath: association.project_path, + detectedAt: association.detected_at, + lastSeenAt: association.last_seen_at, + })), + }; +} + +function toSkillWithInvocations(row: SkillWithInvocationsRow): SkillWithInvocations { + return { + skillId: row.skill_id, + packId: row.pack_id, + name: row.name, + harness: row.harness, + description: row.description, + invocationCount: row.invocation_count, + lastUsedAt: row.last_invoked_at, + }; +} + +function toSkillInvocation(row: SkillInvocationRow): SkillInvocation { + return { + eventId: row.event_id, + sessionId: row.session_id, + sessionName: row.session_name, + harness: row.session_harness, + model: row.session_model, + createdAt: row.created_at, + }; +} + /** * List all packs, collapsed to one row per `pack_id` (the user-facing handle). * Includes harness fan-out and skill count. @@ -162,7 +255,7 @@ interface PackListRow extends Record { * (e.g. a marketplace pack with several plugins at different versions) -- * avoids picking one arbitrary value and presenting it as authoritative. */ -export async function listPacks(db: DbClient): Promise { +export async function listPacks(db: DbClient): Promise { const result = await db.query( `SELECT p.pack_id, @@ -183,7 +276,7 @@ export async function listPacks(db: DbClient): Promise { GROUP BY p.pack_id ORDER BY p.pack_id ASC`, ); - return result.rows; + return result.rows.map(toInstalledPack); } interface PackInstallRow extends Record { @@ -217,15 +310,6 @@ interface ProjectAssociationRow extends Record { last_seen_at: string; } -interface PackDetail { - pack_id: string; - version: string | null; - harnesses: string[]; - installs: PackInstallRow[]; - skills: SkillRow[]; - associations: ProjectAssociationRow[]; -} - /** * Get one pack by `pack_id`, returning installs (one row per harness/install * path), skills, and project associations. Tombstoned installs are excluded. @@ -233,7 +317,7 @@ interface PackDetail { export async function getPack( db: DbClient, packId: string, -): Promise { +): Promise { const installResult = await db.query( `SELECT pack_id, harness, install_path, install_kind, source_url, version, detected_at, last_seen_at @@ -256,14 +340,7 @@ export async function getPack( [packId], ); - return { - pack_id: packId, - version: installs[0].version, - harnesses: [...new Set(installs.map((i) => i.harness))], - installs, - skills, - associations: assocResult.rows, - }; + return toInstalledPackDetail(packId, installs, skills, assocResult.rows); } export async function listSkillsForPack( @@ -309,7 +386,7 @@ function skillNameFromPromptSql(tableAlias: string): string { END`; } -interface SkillWithInvocations extends SkillRow { +interface SkillWithInvocationsRow extends SkillRow { invocation_count: number; last_invoked_at: string | null; } @@ -329,7 +406,7 @@ interface SkillWithInvocations extends SkillRow { * Codex patch (default 'claude' for legacy rows). */ export async function listSkills(db: DbClient): Promise { - const result = await db.query( + const result = await db.query( `SELECT s.skill_id, s.pack_id, @@ -359,7 +436,7 @@ export async function listSkills(db: DbClient): Promise WHERE s.uninstalled_at IS NULL ORDER BY (s.pack_id IS NULL) ASC, s.pack_id ASC, s.name ASC, s.harness ASC`, ); - return result.rows; + return result.rows.map(toSkillWithInvocations); } interface SkillInvocationRow extends Record { @@ -390,7 +467,7 @@ export async function listSkillInvocations( offset = 0, harness = null as string | null, } = {}, -): Promise { +): Promise { const harnessClause = harness ? "AND COALESCE(NULLIF(sess.harness, ''), 'claude') = $2" : ""; @@ -432,7 +509,7 @@ export async function listSkillInvocations( LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params, ); - return result.rows; + return result.rows.map(toSkillInvocation); } // ──────────────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/plans/plan-store.ts b/apps/desktop/src/main/plans/plan-store.ts index 274e5321..5d8c7c5e 100644 --- a/apps/desktop/src/main/plans/plan-store.ts +++ b/apps/desktop/src/main/plans/plan-store.ts @@ -15,6 +15,10 @@ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { Results } from "@electric-sql/pglite"; +import type { + PlanRecord, + PlanVersionRecord, +} from "../../shared/agent-db-contract.js"; type DbClient = { query>( @@ -447,11 +451,64 @@ export function extractPlansFromPlansDir(plansDir: string): PlanCapture[] { interface PlanRow extends Record { id: string; plan_key: string | null; + title: string | null; + status: string; + source: string | null; + capture_method: string | null; harness: string | null; created_from_session_id: string | null; file_path: string | null; source_log_path: string | null; + needs_confirmation: boolean; + confidence: number; + created_at: string | null; updated_at: string | null; + latest_content?: string | null; + version_count?: number | null; +} + +interface PlanVersionRow extends Record { + id: string; + plan_id: string; + version_number: number; + content_markdown: string | null; + content_sha256: string | null; + author_type: string | null; + capture_method: string | null; + created_at: string | null; +} + +function toPlanRecord(row: PlanRow): PlanRecord { + return { + id: row.id, + title: row.title, + status: row.status, + source: row.source, + captureMethod: row.capture_method, + harness: row.harness, + sessionId: row.created_from_session_id, + filePath: row.file_path, + sourceLogPath: row.source_log_path, + needsConfirmation: row.needs_confirmation, + confidence: row.confidence, + createdAt: row.created_at, + updatedAt: row.updated_at, + latestContent: row.latest_content ?? null, + versionCount: row.version_count ?? 0, + }; +} + +function toPlanVersionRecord(row: PlanVersionRow): PlanVersionRecord { + return { + id: row.id, + planId: row.plan_id, + versionNumber: row.version_number, + contentMarkdown: row.content_markdown, + contentSha256: row.content_sha256, + authorType: row.author_type, + captureMethod: row.capture_method, + createdAt: row.created_at, + }; } async function findExistingPlan( @@ -764,15 +821,32 @@ function buildPlanListFilters(opts: PlanListFilters): { export async function listPlans( db: DbClient, opts: PlanListFilters = {}, -): Promise[]> { +): Promise { const { limit = 100, offset = 0 } = opts; const filters = buildPlanListFilters(opts); - const result = await db.query( - `SELECT * FROM plans${filters.clause} - ORDER BY updated_at DESC LIMIT $${filters.nextParam} OFFSET $${filters.nextParam + 1}`, + const result = await db.query( + `SELECT + p.*, + latest.content_markdown AS latest_content, + COALESCE(version_counts.version_count, 0)::int AS version_count + FROM plans p + LEFT JOIN LATERAL ( + SELECT content_markdown + FROM plan_versions + WHERE plan_id = p.id + ORDER BY version_number DESC + LIMIT 1 + ) latest ON true + LEFT JOIN ( + SELECT plan_id, COUNT(*)::int AS version_count + FROM plan_versions + GROUP BY plan_id + ) version_counts ON version_counts.plan_id = p.id + ${filters.clause} + ORDER BY p.updated_at DESC LIMIT $${filters.nextParam} OFFSET $${filters.nextParam + 1}`, [...filters.params, limit, offset], ); - return result.rows; + return result.rows.map(toPlanRecord); } export async function countPlans( @@ -790,8 +864,8 @@ export async function countPlans( export async function getPlanVersions( db: DbClient, planId: string, -): Promise[]> { - const result = await db.query( +): Promise { + const result = await db.query( `SELECT id, plan_id, version_number, content_markdown, content_sha256, author_type, source_session_id, source_event_ref, capture_method, created_at @@ -799,18 +873,36 @@ export async function getPlanVersions( ORDER BY version_number ASC`, [planId], ); - return result.rows; + return result.rows.map(toPlanVersionRecord); } export async function getPlan( db: DbClient, id: string, -): Promise<(Record & { versions: Record[] }) | null> { - const result = await db.query(`SELECT * FROM plans WHERE id = $1`, [id]); +): Promise { + const result = await db.query( + `SELECT + p.*, + latest.content_markdown AS latest_content, + COALESCE(version_counts.version_count, 0)::int AS version_count + FROM plans p + LEFT JOIN LATERAL ( + SELECT content_markdown + FROM plan_versions + WHERE plan_id = p.id + ORDER BY version_number DESC + LIMIT 1 + ) latest ON true + LEFT JOIN ( + SELECT plan_id, COUNT(*)::int AS version_count + FROM plan_versions + GROUP BY plan_id + ) version_counts ON version_counts.plan_id = p.id + WHERE p.id = $1`, + [id], + ); const plan = result.rows[0]; - if (!plan) return null; - const versions = await getPlanVersions(db, id); - return { ...plan, versions }; + return plan ? toPlanRecord(plan) : null; } // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/pull-requests/pr-store.ts b/apps/desktop/src/main/pull-requests/pr-store.ts index b9a3c9e9..02a8f146 100644 --- a/apps/desktop/src/main/pull-requests/pr-store.ts +++ b/apps/desktop/src/main/pull-requests/pr-store.ts @@ -16,6 +16,11 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir } from "node:os"; import type { Results } from "@electric-sql/pglite"; +import type { + PrRecord, + PrSessionGroup, + PrStats, +} from "../../shared/agent-db-contract.js"; type DbClient = { query>( @@ -24,6 +29,36 @@ type DbClient = { ): Promise>; }; +interface PrRow extends Record { + id: string; + session_id: string | null; + pr_url: string; + pr_number: number | null; + repo_full_name: string | null; + branch_name: string | null; + head_sha: string | null; + title: string | null; + harness: string | null; + observed_at: string | null; + created_at: string | null; +} + +function toPrRecord(row: PrRow): PrRecord { + return { + id: row.id, + sessionId: row.session_id, + prUrl: row.pr_url, + prNumber: row.pr_number, + repoFullName: row.repo_full_name, + branchName: row.branch_name, + headSha: row.head_sha, + title: row.title, + harness: row.harness, + observedAt: row.observed_at, + createdAt: row.created_at, + }; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -593,15 +628,15 @@ function buildPrFilter(opts: PrListFilters): { export async function listPullRequests( db: DbClient, opts: PrListFilters = {}, -): Promise[]> { +): Promise { const { limit = 100, offset = 0 } = opts; const { where, params, nextParam } = buildPrFilter(opts); - const result = await db.query( + const result = await db.query( `SELECT * FROM pull_requests${where} ORDER BY observed_at DESC LIMIT $${nextParam} OFFSET $${nextParam + 1}`, [...params, limit, offset], ); - return result.rows; + return result.rows.map(toPrRecord); } export async function countPullRequests( @@ -623,12 +658,6 @@ export async function countRepos(db: DbClient): Promise { return result.rows[0]?.c ?? 0; } -export interface PrStats { - totalPrs: number; - totalRepos: number; - totalSessions: number; -} - export async function getPrStats(db: DbClient): Promise { const result = await db.query<{ total_prs: number; @@ -644,8 +673,8 @@ export async function getPrStats(db: DbClient): Promise { const row = result.rows[0]; return { totalPrs: row?.total_prs ?? 0, - totalRepos: row?.total_repos ?? 0, - totalSessions: row?.total_sessions ?? 0, + repos: row?.total_repos ?? 0, + sessionsWithPrs: row?.total_sessions ?? 0, }; } @@ -653,21 +682,10 @@ export async function getPrStats(db: DbClient): Promise { // DB: session-grouped PR listing // --------------------------------------------------------------------------- -interface SessionWithPrs extends Record { - session_id: string | null; - session_name: string | null; - session_started_at: string | null; - session_cwd: string | null; - pr_count: number; - last_pr_at: string | null; - harness: string | null; - pull_requests: Record[]; -} - export async function listPrSessions( db: DbClient, opts: { limit?: number; offset?: number } = {}, -): Promise { +): Promise { const { limit = 100, offset = 0 } = opts; const result = await db.query<{ session_id: string | null; @@ -694,18 +712,22 @@ export async function listPrSessions( [limit, offset], ); - const rows: SessionWithPrs[] = []; + const rows: PrSessionGroup[] = []; for (const row of result.rows) { - const prsResult = await db.query( - `SELECT id, pr_url, pr_number, repo_full_name, branch_name, head_sha, - title, harness, observed_at + const prsResult = await db.query( + `SELECT id, session_id, pr_url, pr_number, repo_full_name, branch_name, head_sha, + title, harness, observed_at, created_at FROM pull_requests WHERE session_id IS NOT DISTINCT FROM $1 ORDER BY observed_at DESC`, [row.session_id], ); rows.push({ - ...row, - pull_requests: prsResult.rows, + sessionId: row.session_id ?? "unknown", + sessionName: row.session_name, + cwd: row.session_cwd, + harness: row.harness, + startedAt: row.session_started_at, + prs: prsResult.rows.map(toPrRecord), }); } return rows; diff --git a/apps/desktop/src/renderer/components/features/CatalogCard.tsx b/apps/desktop/src/renderer/components/features/CatalogCard.tsx index b87c2ce8..34ff8f38 100644 --- a/apps/desktop/src/renderer/components/features/CatalogCard.tsx +++ b/apps/desktop/src/renderer/components/features/CatalogCard.tsx @@ -23,7 +23,7 @@ export function CatalogCard({ const harnesses = catalogHarnesses(entry); const installedHarnesses = catalogInstalledHarnesses(entry); const isInstalled = installedHarnesses.length > 0; - const starHistory = entry.history?.map((h) => h.stars) ?? []; + const starHistory = catalogStarHistory(entry); return ( h.stars) : []; +} + function formatCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; diff --git a/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx b/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx index b8e1fca0..2b6a64f2 100644 --- a/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx +++ b/apps/desktop/src/renderer/components/features/CoreFeaturesView.tsx @@ -62,7 +62,7 @@ export function SkillsView() { return ; } - const rows = skills ?? []; + const rows = arrayOrEmpty(skills); return ( @@ -120,7 +120,7 @@ export function ToolsView() { return ; } - const rows = tools ?? []; + const rows = arrayOrEmpty(tools); return ( @@ -176,7 +176,7 @@ export function SubAgentsView() { return ; } - const rows = subagents ?? []; + const rows = arrayOrEmpty(subagents); return ( @@ -289,3 +289,7 @@ function formatDate(value: string | null): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } + +function arrayOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} diff --git a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx index 1aef4906..a969e3ae 100644 --- a/apps/desktop/src/renderer/components/features/PacksCatalog.tsx +++ b/apps/desktop/src/renderer/components/features/PacksCatalog.tsx @@ -139,6 +139,15 @@ export function PacksCatalog() { [filteredCatalog], ); + const installedPackRows = useMemo( + () => (Array.isArray(installedPacks) ? installedPacks : []), + [installedPacks], + ); + const recentProjectRows = useMemo( + () => (Array.isArray(recentProjects) ? recentProjects : []), + [recentProjects], + ); + const hasProjectScopedActions = useMemo( () => filteredCatalog.some((entry) => @@ -152,10 +161,10 @@ export function PacksCatalog() { ); useEffect(() => { - if (!selectedProjectCwd && recentProjects?.[0]) { - setSelectedProjectCwd(recentProjects[0]); + if (!selectedProjectCwd && recentProjectRows[0]) { + setSelectedProjectCwd(recentProjectRows[0]); } - }, [recentProjects, selectedProjectCwd]); + }, [recentProjectRows, selectedProjectCwd]); // -- Actions -- @@ -255,7 +264,7 @@ export function PacksCatalog() { onInstall={handleInstall} onUninstall={handleUninstall} installing={installing} - recentProjects={recentProjects ?? []} + recentProjects={recentProjectRows} selectedProjectCwd={selectedProjectCwd} onProjectCwdChange={setSelectedProjectCwd} installError={installError} @@ -280,7 +289,7 @@ export function PacksCatalog() {
{hasProjectScopedActions && ( {/* Installed packs (from local detection) */} - {!installedLoading && installedPacks && installedPacks.length > 0 && ( + {!installedLoading && installedPackRows.length > 0 && (
@@ -364,16 +373,16 @@ export function PacksCatalog() { - {installedPacks.map((pack) => ( + {installedPackRows.map((pack) => ( handleCardClick(pack.packId)} + onClick={() => handleCardClick(installedPackId(pack))} > - {pack.packId} + {installedPackId(pack)}
- {pack.harnesses.map((h) => ( + {installedPackHarnesses(pack).map((h) => ( {h} ))}
@@ -473,7 +482,10 @@ function PackDetailView({ const displayName = catalogEntry?.displayName ?? packId; const description = catalogEntry?.descriptionLive ?? catalogEntry?.description; - const starHistory = catalogEntry?.history?.map((h) => h.stars) ?? []; + const starHistory = catalogEntry ? catalogStarHistory(catalogEntry) : []; + const skills = installedPackDetailSkills(packDetail); + const associations = installedPackDetailAssociations(packDetail); + const contentsCache = catalogContentsCache(catalogEntry); const hasProjectScopedActions = catalogEntry ? catalogHarnesses(catalogEntry).some( (harness) => @@ -583,8 +595,8 @@ function PackDetailView({ )} {/* Skills list */} - {packDetail && packDetail.skills.length > 0 && ( - + {skills.length > 0 && ( +
@@ -596,7 +608,7 @@ function PackDetailView({ - {packDetail.skills.map((skill) => ( + {skills.map((skill) => ( {skill.name ?? skill.skillId} @@ -615,10 +627,10 @@ function PackDetailView({ )} {/* Project associations */} - {packDetail && packDetail.associations.length > 0 && ( + {associations.length > 0 && (
- {packDetail.associations.map((assoc) => ( + {associations.map((assoc) => (
{assoc.projectPath} @@ -640,7 +652,7 @@ function PackDetailView({ )} {/* Contents */} - {catalogEntry?.contentsCache && catalogEntry.contentsCache.length > 0 && ( + {contentsCache.length > 0 && (
@@ -652,7 +664,7 @@ function PackDetailView({ - {catalogEntry.contentsCache.map((item) => ( + {contentsCache.map((item) => ( {item.name} @@ -735,6 +747,16 @@ function catalogInstalledHarnesses(entry: CatalogEntry): string[] { return Array.isArray(entry.installedHarnesses) ? entry.installedHarnesses : []; } +function catalogStarHistory(entry: CatalogEntry): number[] { + return Array.isArray(entry.history) ? entry.history.map((h) => h.stars) : []; +} + +function catalogContentsCache( + entry: CatalogEntry | null, +): NonNullable { + return Array.isArray(entry?.contentsCache) ? entry.contentsCache : []; +} + function catalogDisplayName(entry: CatalogEntry): string { return typeof entry.displayName === "string" ? entry.displayName : catalogPackId(entry); } @@ -743,6 +765,26 @@ function catalogPackId(entry: CatalogEntry): string { return typeof entry.packId === "string" ? entry.packId : ""; } +function installedPackId(pack: InstalledPack): string { + return typeof pack.packId === "string" ? pack.packId : ""; +} + +function installedPackHarnesses(pack: InstalledPack): string[] { + return Array.isArray(pack.harnesses) ? pack.harnesses : []; +} + +function installedPackDetailSkills( + packDetail: InstalledPackDetail | null, +): InstalledPackDetail["skills"] { + return Array.isArray(packDetail?.skills) ? packDetail.skills : []; +} + +function installedPackDetailAssociations( + packDetail: InstalledPackDetail | null, +): InstalledPackDetail["associations"] { + return Array.isArray(packDetail?.associations) ? packDetail.associations : []; +} + function resolveProjectCwdForAction( entry: CatalogEntry | null | undefined, harness: string, diff --git a/apps/desktop/src/renderer/components/features/PlansView.tsx b/apps/desktop/src/renderer/components/features/PlansView.tsx index a1acc3ce..a20f271f 100644 --- a/apps/desktop/src/renderer/components/features/PlansView.tsx +++ b/apps/desktop/src/renderer/components/features/PlansView.tsx @@ -25,9 +25,10 @@ export function PlansView() { 10_000, ); + const planList = arrayOrEmpty(plans); const selectedPlan = useMemo( - () => plans?.find((p) => p.id === selectedPlanId) ?? null, - [plans, selectedPlanId], + () => planList.find((p) => p.id === selectedPlanId) ?? null, + [planList, selectedPlanId], ); const { data: versions } = useQueryCache( @@ -75,7 +76,7 @@ export function PlansView() { return ; } - const planList = plans ?? []; + const versionList = arrayOrEmpty(versions); return ( @@ -122,10 +123,10 @@ export function PlansView() { )} {/* Version history */} - {showVersions && versions && versions.length > 0 && ( + {showVersions && versionList.length > 0 && (
- {versions.map((v) => ( + {versionList.map((v) => ( ))}
@@ -320,3 +321,7 @@ function formatDate(value: string | null | undefined): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } + +function arrayOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} diff --git a/apps/desktop/src/renderer/components/features/PullRequestsView.tsx b/apps/desktop/src/renderer/components/features/PullRequestsView.tsx index 56dcaac8..cb5393db 100644 --- a/apps/desktop/src/renderer/components/features/PullRequestsView.tsx +++ b/apps/desktop/src/renderer/components/features/PullRequestsView.tsx @@ -69,6 +69,8 @@ export function PullRequestsView() { } const prStats = stats ?? { totalPrs: 0, sessionsWithPrs: 0, repos: 0 }; + const sessionGroups = arrayOrEmpty(sessions); + const prs = arrayOrEmpty(allPrs); return ( @@ -104,9 +106,9 @@ export function PullRequestsView() { - {!sessionsLoading && sessions && sessions.length > 0 ? ( + {!sessionsLoading && sessionGroups.length > 0 ? (
- {sessions.map((group) => ( + {sessionGroups.map((group) => ( - {!prsLoading && allPrs && allPrs.length > 0 ? ( - + {!prsLoading && prs.length > 0 ? ( + ) : prsLoading ? ( ) : ( @@ -145,6 +147,7 @@ function SessionGroupCard({ group: PrSessionGroup; onOpenPr: (id: string) => void; }) { + const prs = prGroupRecords(group); return (
@@ -161,13 +164,13 @@ function SessionGroupCard({
- {group.prs.length} PR{group.prs.length !== 1 ? "s" : ""} + {prs.length} PR{prs.length !== 1 ? "s" : ""}
{/* PR chips */}
- {group.prs.map((pr) => ( + {prs.map((pr) => ( ))}
@@ -279,3 +282,11 @@ function formatDate(value: string | null | undefined): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } + +function arrayOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + +function prGroupRecords(group: PrSessionGroup): PrRecord[] { + return Array.isArray(group.prs) ? group.prs : []; +} diff --git a/apps/desktop/test/ported-screen-store-contract.test.ts b/apps/desktop/test/ported-screen-store-contract.test.ts new file mode 100644 index 00000000..502793e4 --- /dev/null +++ b/apps/desktop/test/ported-screen-store-contract.test.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + getPack, + listPacks, + listSkillInvocations, + listSkills, +} from "../src/main/packs/pack-store.js"; +import { + getPlanVersions, + listPlans, +} from "../src/main/plans/plan-store.js"; +import { + getPrStats, + listPrSessions, + listPullRequests, +} from "../src/main/pull-requests/pr-store.js"; + +type QueryResult> = { rows: T[] }; +type StubDb = { + query>( + sql: string, + params?: unknown[], + ): Promise>; +}; + +function rows>(items: Record[]): QueryResult { + return { rows: items as T[] }; +} + +function createPackDb(): StubDb { + return { + async query>(sql: string): Promise> { + if (sql.includes("FROM agent_packs p")) { + return rows([ + { + pack_id: "demo-pack", + version: "1.0.0", + harnesses: "claude,codex", + install_count: 2, + first_detected_at: "2026-06-08T12:00:00.000Z", + last_seen_at: "2026-06-08T13:00:00.000Z", + skill_count: 2, + }, + ]); + } + if (sql.includes("FROM agent_packs") && sql.includes("install_path")) { + return rows([ + { + pack_id: "demo-pack", + harness: "claude", + install_path: "/tmp/demo", + install_kind: "directory", + source_url: "https://example.test/demo", + version: "1.0.0", + detected_at: "2026-06-08T12:00:00.000Z", + last_seen_at: "2026-06-08T13:00:00.000Z", + }, + ]); + } + if (sql.includes("FROM project_pack_associations")) { + return rows([ + { + project_path: "/tmp/project", + pack_id: "demo-pack", + detected_at: "2026-06-08T12:00:00.000Z", + last_seen_at: "2026-06-08T13:00:00.000Z", + }, + ]); + } + if (sql.includes("FROM skills")) { + return rows([ + { + skill_id: "skill-1", + pack_id: "demo-pack", + harness: "claude", + install_path: "/tmp/demo", + name: "demo-skill", + version: "1.0.0", + description: "Demo skill", + source_url: null, + detected_at: "2026-06-08T12:00:00.000Z", + last_seen_at: "2026-06-08T13:00:00.000Z", + invocation_count: 4, + last_invoked_at: "2026-06-08T14:00:00.000Z", + }, + ]); + } + if (sql.includes("FROM events e")) { + return rows([ + { + event_id: "event-1", + session_id: "session-1", + created_at: "2026-06-08T13:00:00.000Z", + summary: null, + data: null, + session_name: "Session", + session_cwd: "/tmp/project", + session_harness: "claude", + session_model: "sonnet", + }, + ]); + } + return rows([]); + }, + }; +} + +function createPlanDb(): StubDb { + return { + async query>(sql: string): Promise> { + if (sql.includes("FROM plans p")) { + return rows([ + { + id: "plan-1", + plan_key: "plan-key", + title: "Plan", + status: "captured", + source: "test", + capture_method: "extractor", + harness: "claude", + created_from_session_id: "session-1", + file_path: "/tmp/plan.md", + source_log_path: "/tmp/session.jsonl", + needs_confirmation: true, + confidence: 0.8, + created_at: "2026-06-08T12:00:00.000Z", + updated_at: "2026-06-08T13:00:00.000Z", + latest_content: "# Plan", + version_count: 2, + }, + ]); + } + if (sql.includes("FROM plan_versions")) { + return rows([ + { + id: "version-1", + plan_id: "plan-1", + version_number: 1, + content_markdown: "# Plan", + content_sha256: "abc", + author_type: "agent", + capture_method: "extractor", + created_at: "2026-06-08T12:00:00.000Z", + }, + ]); + } + return rows([]); + }, + }; +} + +function createPrDb(): StubDb { + return { + async query>(sql: string): Promise> { + if (sql.includes("COUNT(*)::int AS total_prs")) { + return rows([{ total_prs: 3, total_repos: 2, total_sessions: 1 }]); + } + if (sql.includes("FROM pull_requests pr")) { + return rows([ + { + session_id: "session-1", + session_name: "Session", + session_started_at: "2026-06-08T12:00:00.000Z", + session_cwd: "/tmp/project", + pr_count: 1, + last_pr_at: "2026-06-08T13:00:00.000Z", + harness: "claude", + }, + ]); + } + if (sql.includes("FROM pull_requests")) { + return rows([ + { + id: "pr-1", + session_id: "session-1", + pr_url: "https://github.com/acme/repo/pull/12", + pr_number: 12, + repo_full_name: "acme/repo", + branch_name: "feature", + head_sha: "abc", + title: "Demo PR", + harness: "claude", + observed_at: "2026-06-08T13:00:00.000Z", + created_at: "2026-06-08T12:30:00.000Z", + }, + ]); + } + return rows([]); + }, + }; +} + +describe("ported screen store contracts", () => { + test("pack and skill stores return renderer DTO arrays", async () => { + const db = createPackDb(); + const packs = await listPacks(db); + const pack = await getPack(db, "demo-pack"); + const skills = await listSkills(db); + const invocations = await listSkillInvocations(db, "demo-skill"); + + assert.deepEqual(packs[0]?.harnesses, ["claude", "codex"]); + assert.equal(pack?.packId, "demo-pack"); + assert.equal(pack?.installs[0]?.installPath, "/tmp/demo"); + assert.equal(pack?.skills[0]?.skillId, "skill-1"); + assert.equal(pack?.associations[0]?.projectPath, "/tmp/project"); + assert.equal(skills[0]?.skillId, "skill-1"); + assert.equal(skills[0]?.invocationCount, 4); + assert.equal(invocations[0]?.eventId, "event-1"); + assert.equal(invocations[0]?.sessionName, "Session"); + }); + + test("plan store returns renderer DTO fields", async () => { + const db = createPlanDb(); + const plans = await listPlans(db); + const versions = await getPlanVersions(db, "plan-1"); + + assert.equal(plans[0]?.captureMethod, "extractor"); + assert.equal(plans[0]?.sessionId, "session-1"); + assert.equal(plans[0]?.latestContent, "# Plan"); + assert.equal(plans[0]?.versionCount, 2); + assert.equal(versions[0]?.planId, "plan-1"); + assert.equal(versions[0]?.versionNumber, 1); + }); + + test("pull request store returns renderer DTO fields", async () => { + const db = createPrDb(); + const stats = await getPrStats(db); + const prs = await listPullRequests(db); + const sessions = await listPrSessions(db); + + assert.deepEqual(stats, { totalPrs: 3, sessionsWithPrs: 1, repos: 2 }); + assert.equal(prs[0]?.prUrl, "https://github.com/acme/repo/pull/12"); + assert.equal(prs[0]?.repoFullName, "acme/repo"); + assert.equal(sessions[0]?.sessionId, "session-1"); + assert.equal(sessions[0]?.prs[0]?.prNumber, 12); + }); +});