diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 78f60e184..c2221b426 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -21,6 +21,8 @@ import type { EditorTheme, KeyId, MarkdownTheme, + OverlayHandle, + OverlayOptions, SlashCommand, } from "@singularity-forge/pi-tui"; import { @@ -1837,6 +1839,64 @@ export class InteractiveMode { /** * Hide the extension selector. */ + showExtensionCustom( + factory: ( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + done: (result: T) => void, + ) => + | (Component & { dispose?(): void }) + | Promise, + options?: { + overlay?: boolean; + overlayOptions?: OverlayOptions | (() => OverlayOptions); + onHandle?: (handle: OverlayHandle) => void; + }, + ): Promise { + if (options?.overlay === false) { + // Non-overlay custom components are not supported (yet) + return Promise.reject( + new Error("Non-overlay custom components are not supported"), + ); + } + + return new Promise((resolve) => { + const done = (result: T) => { + // Hide the overlay before resolving the promise + this.ui.hideOverlay(); + this.ui.setFocus(this.editor); + resolve(result); + }; + + void (async () => { + try { + const component = await factory( + this.ui, + theme, + this.keybindings, + done, + ); + const overlayOptions = + typeof options?.overlayOptions === "function" + ? options.overlayOptions() + : options?.overlayOptions; + const handle = this.ui.showOverlay(component, overlayOptions); + options?.onHandle?.(handle); + } catch (error) { + this.ui.hideOverlay(); + this.ui.setFocus(this.editor); + this.showError( + `Failed to create custom UI component: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + resolve(undefined as T); // Resolve with undefined on error + } + })(); + }); + } + private hideExtensionSelector(): void { this.extensionSelector?.dispose(); this.editorContainer.clear(); diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 691b289ab..bdd1a74dd 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -344,6 +344,8 @@ function registerSigtermHandler(currentBasePath) { // mismatch that occurs when auto-mode terminates via signal. writeParityHeartbeat(currentBasePath, { ts: new Date().toISOString(), + ...(s.currentUokRunId ? { runId: s.currentUokRunId } : {}), + sessionId: s.cmdCtx?.sessionManager?.getSessionId?.(), path: pathLabel, flags: { ...flags }, phase: "exit", diff --git a/src/resources/extensions/sf/health-widget.js b/src/resources/extensions/sf/health-widget.js index 9be7a494d..c503e9a3c 100644 --- a/src/resources/extensions/sf/health-widget.js +++ b/src/resources/extensions/sf/health-widget.js @@ -92,6 +92,14 @@ function loadHealthWidgetData(basePath) { } // ── Widget init ──────────────────────────────────────────────────────────────── const REFRESH_INTERVAL_MS = 60_000; +function safeSetWidget(ctx, key, content, options) { + try { + ctx.ui?.setWidget?.(key, content, options); + return true; + } catch { + return false; + } +} /** * Initialize the always-on sf-health widget (belowEditor). * Call once from the extension entry point after context is available. @@ -101,11 +109,16 @@ export function initHealthWidget(ctx) { const basePath = projectRoot(); // String-array fallback — used in RPC mode (factory is a no-op there) const initialData = loadHealthWidgetData(basePath); - ctx.ui.setWidget("sf-health", buildHealthLines(initialData), { - placement: "belowEditor", - }); + if ( + !safeSetWidget(ctx, "sf-health", buildHealthLines(initialData), { + placement: "belowEditor", + }) + ) { + return; + } // Factory-based widget for TUI mode — replaces the string-array above - ctx.ui.setWidget( + safeSetWidget( + ctx, "sf-health", (_tui, _theme) => { let data = initialData; diff --git a/src/resources/extensions/sf/notification-widget.js b/src/resources/extensions/sf/notification-widget.js index 3e3f6cf44..e31d7283a 100644 --- a/src/resources/extensions/sf/notification-widget.js +++ b/src/resources/extensions/sf/notification-widget.js @@ -21,6 +21,14 @@ export function buildNotificationWidgetLines() { } // ─── Widget init ──────────────────────────────────────────────────────── const REFRESH_INTERVAL_MS = 30_000; +function safeSetWidget(ctx, key, content, options) { + try { + ctx.ui?.setWidget?.(key, content, options); + return true; + } catch { + return false; + } +} /** * Initialize the always-on notification widget (belowEditor). * Call once from session_start after the notification store is initialized. @@ -28,11 +36,16 @@ const REFRESH_INTERVAL_MS = 30_000; export function initNotificationWidget(ctx) { if (!ctx.hasUI) return; // String-array fallback for RPC mode - ctx.ui.setWidget("sf-notifications", buildNotificationWidgetLines(), { - placement: "belowEditor", - }); + if ( + !safeSetWidget(ctx, "sf-notifications", buildNotificationWidgetLines(), { + placement: "belowEditor", + }) + ) { + return; + } // Factory-based widget for TUI mode - ctx.ui.setWidget( + safeSetWidget( + ctx, "sf-notifications", (_tui, _theme) => { let cachedLines; diff --git a/src/resources/extensions/sf/tests/uok-parity-report.test.mjs b/src/resources/extensions/sf/tests/uok-parity-report.test.mjs new file mode 100644 index 000000000..b1c3f8d66 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-parity-report.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + buildParityReport, + hasCurrentParityWarning, + UNMATCHED_RUN_STALE_MS, +} from "../uok/parity-report.js"; + +const NOW = Date.parse("2026-05-06T00:00:00.000Z"); + +test("buildParityReport_legacy_anonymous_missing_exit_is_historical_not_current", () => { + const report = buildParityReport( + [ + { ts: "2026-05-05T17:21:52.209Z", path: "uok-kernel", phase: "enter" }, + { + ts: "2026-05-05T17:25:52.439Z", + path: "uok-kernel", + phase: "exit", + status: "ok", + }, + { ts: "2026-05-05T17:25:18.803Z", path: "uok-kernel", phase: "enter" }, + ], + "/tmp/uok-parity.jsonl", + NOW, + ); + + assert.equal(report.legacyMissingExitEvents, 1); + assert.equal(report.missingExitEvents, 0); + assert.deepEqual(report.criticalMismatches, []); + assert.equal(hasCurrentParityWarning(report), false); +}); + +test("buildParityReport_fresh_run_missing_exit_is_current_warning", () => { + const report = buildParityReport( + [ + { + ts: new Date(NOW - 5_000).toISOString(), + runId: "uok-run-1", + path: "uok-kernel", + phase: "enter", + }, + ], + "/tmp/uok-parity.jsonl", + NOW, + ); + + assert.equal(report.missingExitEvents, 1); + assert.equal(report.freshUnmatchedRuns[0].runId, "uok-run-1"); + assert.match(report.criticalMismatches[0], /uok-run-1/); + assert.equal(hasCurrentParityWarning(report), true); +}); + +test("buildParityReport_stale_run_missing_exit_is_historical_not_current", () => { + const report = buildParityReport( + [ + { + ts: new Date(NOW - UNMATCHED_RUN_STALE_MS - 1_000).toISOString(), + runId: "uok-old-run", + path: "uok-kernel", + phase: "enter", + }, + ], + "/tmp/uok-parity.jsonl", + NOW, + ); + + assert.equal(report.missingExitEvents, 0); + assert.equal(report.historicalUnmatchedRuns[0].runId, "uok-old-run"); + assert.deepEqual(report.criticalMismatches, []); + assert.equal(hasCurrentParityWarning(report), false); +}); + +test("buildParityReport_run_exit_balances_enter", () => { + const report = buildParityReport( + [ + { + ts: new Date(NOW - 10_000).toISOString(), + runId: "uok-run-2", + path: "uok-kernel", + phase: "enter", + }, + { + ts: new Date(NOW - 5_000).toISOString(), + runId: "uok-run-2", + path: "uok-kernel", + phase: "exit", + status: "ok", + }, + ], + "/tmp/uok-parity.jsonl", + NOW, + ); + + assert.equal(report.missingExitEvents, 0); + assert.deepEqual(report.unmatchedRuns, []); + assert.equal(hasCurrentParityWarning(report), false); +}); diff --git a/src/resources/extensions/sf/uok-parity-summary.js b/src/resources/extensions/sf/uok-parity-summary.js index 20415d89b..fcffe2e4b 100644 --- a/src/resources/extensions/sf/uok-parity-summary.js +++ b/src/resources/extensions/sf/uok-parity-summary.js @@ -1,5 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { + hasCurrentParityWarning, + writeParityReport, +} from "./uok/parity-report.js"; /** * Read the last UOK parity report from /.sf/runtime/uok-parity-report.json * and surface any divergences or errors via ctx.ui?.notify?.(). @@ -11,14 +15,18 @@ export async function summarizeParityReport(basePath, ctx, pi) { if (!existsSync(reportPath)) return; let report; try { - report = JSON.parse(readFileSync(reportPath, "utf-8")); + report = writeParityReport(basePath); } catch { - // Malformed JSON — silently skip - return; + try { + report = JSON.parse(readFileSync(reportPath, "utf-8")); + } catch { + // Malformed JSON — silently skip + return; + } } const mismatches = report.criticalMismatches?.length ?? 0; const errors = report.statuses?.error ?? 0; - if (mismatches > 0 || errors > 0) { + if (hasCurrentParityWarning(report)) { const msg = `UOK parity report shows ${mismatches} critical mismatch${mismatches === 1 ? "" : "es"}, ` + `${errors} error${errors === 1 ? "" : "s"} since ${report.generatedAt}. ` + diff --git a/src/resources/extensions/sf/uok/kernel.js b/src/resources/extensions/sf/uok/kernel.js index cffa768d8..d48a97649 100644 --- a/src/resources/extensions/sf/uok/kernel.js +++ b/src/resources/extensions/sf/uok/kernel.js @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { debugLog } from "../debug-logger.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; import { setAuditEnvelopeEnabled } from "./audit-toggle.js"; @@ -8,7 +9,11 @@ import { resetParityCommitBlock, signalKernelEnter, } from "./parity-diff-capture.js"; -import { writeParityHeartbeat, writeParityReport } from "./parity-report.js"; +import { + hasCurrentParityWarning, + writeParityHeartbeat, + writeParityReport, +} from "./parity-report.js"; function refreshParityReport(basePath) { try { @@ -28,8 +33,14 @@ export async function runAutoLoopWithUok(args) { const prefs = deps.loadEffectiveSFPreferences()?.preferences; const flags = resolveUokFlags(prefs); const previousReport = refreshParityReport(s.basePath); + const runId = `uok-${randomUUID()}`; + s.currentUokRunId = runId; resetParityCommitBlock(); - if (previousReport && previousReport.missingExitEvents > 0) { + if ( + previousReport && + previousReport.missingExitEvents > 0 && + hasCurrentParityWarning(previousReport) + ) { checkAndDrainMissingExit( previousReport.enterEvents, previousReport.exitEvents, @@ -39,6 +50,8 @@ export async function runAutoLoopWithUok(args) { signalKernelEnter(); writeParityHeartbeat(s.basePath, { ts: new Date().toISOString(), + runId, + sessionId: ctx.sessionManager?.getSessionId?.(), path: resolveKernelPathLabel(flags), flags: { ...flags }, phase: "enter", @@ -84,6 +97,8 @@ export async function runAutoLoopWithUok(args) { } finally { writeParityHeartbeat(s.basePath, { ts: new Date().toISOString(), + runId, + sessionId: ctx.sessionManager?.getSessionId?.(), path: resolveKernelPathLabel(flags), flags: { ...flags }, phase: "exit", @@ -91,5 +106,6 @@ export async function runAutoLoopWithUok(args) { ...(error ? { error } : {}), }); refreshParityReport(s.basePath); + if (s.currentUokRunId === runId) s.currentUokRunId = undefined; } } diff --git a/src/resources/extensions/sf/uok/parity-report.js b/src/resources/extensions/sf/uok/parity-report.js index 31dc3f9bf..b4a74b513 100644 --- a/src/resources/extensions/sf/uok/parity-report.js +++ b/src/resources/extensions/sf/uok/parity-report.js @@ -9,6 +9,8 @@ import { import { join } from "node:path"; import { sfRoot } from "../paths.js"; +export const UNMATCHED_RUN_STALE_MS = 30 * 60 * 1000; + function parityLogPath(basePath) { return join(sfRoot(basePath), "runtime", "uok-parity.jsonl"); } @@ -27,6 +29,14 @@ function isParityDiffEvent(value) { typeof value.plane === "string" ); } +function timestampMs(value) { + const ms = Date.parse(String(value ?? "")); + return Number.isFinite(ms) ? ms : undefined; +} +function isFreshTimestamp(value, nowMs, staleMs) { + const ms = timestampMs(value); + return ms !== undefined && nowMs - ms <= staleMs; +} export function parseParityEvents(raw) { return raw .split("\n") @@ -44,12 +54,20 @@ export function parseParityEvents(raw) { } }); } -export function buildParityReport(events, sourcePath) { +export function buildParityReport( + events, + sourcePath, + nowMs = Date.now(), + staleMs = UNMATCHED_RUN_STALE_MS, +) { const paths = {}; const statuses = {}; const criticalMismatches = []; + const runs = new Map(); let enterEvents = 0; let exitEvents = 0; + let legacyEnterEvents = 0; + let legacyExitEvents = 0; let totalDiffs = 0; const divergencesByPlane = { plan: 0, @@ -78,20 +96,77 @@ export function buildParityReport(events, sourcePath) { const heartbeat = event; increment(paths, heartbeat.path); increment(statuses, heartbeat.status); - if (heartbeat.phase === "enter") enterEvents += 1; - if (heartbeat.phase === "exit") exitEvents += 1; + const runId = + typeof heartbeat.runId === "string" && heartbeat.runId.trim().length > 0 + ? heartbeat.runId + : undefined; + if (heartbeat.phase === "enter") { + enterEvents += 1; + if (runId) { + const current = runs.get(runId) ?? { + runId, + path: heartbeat.path, + enterEvents: 0, + exitEvents: 0, + enteredAt: heartbeat.ts, + exitedAt: undefined, + }; + current.enterEvents += 1; + current.path = current.path ?? heartbeat.path; + current.enteredAt = current.enteredAt ?? heartbeat.ts; + runs.set(runId, current); + } else { + legacyEnterEvents += 1; + } + } + if (heartbeat.phase === "exit") { + exitEvents += 1; + if (runId) { + const current = runs.get(runId) ?? { + runId, + path: heartbeat.path, + enterEvents: 0, + exitEvents: 0, + enteredAt: undefined, + exitedAt: heartbeat.ts, + }; + current.exitEvents += 1; + current.path = current.path ?? heartbeat.path; + current.exitedAt = heartbeat.ts; + runs.set(runId, current); + } else { + legacyExitEvents += 1; + } + } if (heartbeat.status === "error") { criticalMismatches.push(heartbeat.error ?? "parity event reported error"); } } - const missingExitEvents = Math.max(0, enterEvents - exitEvents); - if (missingExitEvents > 0) { + const unmatchedRuns = Array.from(runs.values()).filter( + (run) => run.enterEvents > run.exitEvents, + ); + const freshUnmatchedRuns = unmatchedRuns.filter((run) => + isFreshTimestamp(run.enteredAt, nowMs, staleMs), + ); + const historicalUnmatchedRuns = unmatchedRuns.filter( + (run) => !isFreshTimestamp(run.enteredAt, nowMs, staleMs), + ); + const legacyMissingExitEvents = Math.max( + 0, + legacyEnterEvents - legacyExitEvents, + ); + const missingExitEvents = freshUnmatchedRuns.length; + if (freshUnmatchedRuns.length > 0) { + const exampleIds = freshUnmatchedRuns + .slice(0, 3) + .map((run) => run.runId) + .join(", "); criticalMismatches.push( - `uok enter/exit mismatch: ${enterEvents} enters / ${exitEvents} exits`, + `uok run enter/exit mismatch: ${freshUnmatchedRuns.length} run(s) missing exit${exampleIds ? ` (${exampleIds})` : ""}`, ); } return { - generatedAt: new Date().toISOString(), + generatedAt: new Date(nowMs).toISOString(), sourcePath, totalEvents: events.length, paths, @@ -100,10 +175,21 @@ export function buildParityReport(events, sourcePath) { enterEvents, exitEvents, missingExitEvents, + legacyMissingExitEvents, + unmatchedRuns, + freshUnmatchedRuns, + historicalUnmatchedRuns, totalDiffs, divergencesByPlane, }; } + +export function hasCurrentParityWarning(report) { + const criticalMismatches = report?.criticalMismatches ?? []; + const errors = report?.statuses?.error ?? 0; + return criticalMismatches.length > 0 || errors > 0; +} + export function writeParityReport(basePath) { const sourcePath = parityLogPath(basePath); const raw = existsSync(sourcePath) ? readFileSync(sourcePath, "utf-8") : "";