diff --git a/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts b/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts new file mode 100644 index 000000000..2ac10cef3 --- /dev/null +++ b/src/resources/extensions/gsd/bootstrap/notify-interceptor.ts @@ -0,0 +1,34 @@ +// GSD Extension — Notify Interceptor +// Wraps ctx.ui.notify() in-place to persist every notification through the +// notification store. Uses a WeakSet to prevent double-wrapping and handle +// UI context replacement on /reload gracefully. + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; + +import { appendNotification, type NotifySeverity } from "../notification-store.js"; + +// Track which ui context objects have been wrapped to prevent double-install. +// WeakSet allows GC to collect replaced uiContext instances after /reload. +const _wrappedContexts = new WeakSet(); + +/** + * Install the notify interceptor on a context's UI object. + * Mutates ctx.ui.notify in place — the original is called after persistence. + * Safe to call multiple times; no-ops if already installed on the same ui object. + */ +export function installNotifyInterceptor(ctx: ExtensionContext): void { + if (_wrappedContexts.has(ctx.ui)) return; + + const originalNotify = ctx.ui.notify.bind(ctx.ui); + + (ctx.ui as any).notify = (message: string, type?: "info" | "warning" | "error" | "success"): void => { + try { + appendNotification(message, (type ?? "info") as NotifySeverity, "notify"); + } catch { + // Non-fatal — never let persistence break the UI + } + originalNotify(message, type); + }; + + _wrappedContexts.add(ctx.ui); +} diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 910e91b9e..58cef8c50 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -21,6 +21,9 @@ import { resetAskUserQuestionsCache } from "../../ask-user-questions.js"; import { recordToolCall as safetyRecordToolCall, recordToolResult as safetyRecordToolResult } from "../safety/evidence-collector.js"; import { classifyCommand } from "../safety/destructive-guard.js"; import { logWarning as safetyLogWarning } from "../workflow-logger.js"; +import { installNotifyInterceptor } from "./notify-interceptor.js"; +import { initNotificationStore } from "../notification-store.js"; +import { initNotificationWidget } from "../notification-widget.js"; // Skip the welcome screen on the very first session_start — cli.ts already // printed it before the TUI launched. Only re-print on /clear (subsequent sessions). @@ -33,6 +36,9 @@ async function syncServiceTierStatus(ctx: ExtensionContext): Promise { export function registerHooks(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { + initNotificationStore(process.cwd()); + installNotifyInterceptor(ctx); + initNotificationWidget(ctx); resetWriteGateState(); resetToolCallLoopGuard(); resetAskUserQuestionsCache(); @@ -70,6 +76,8 @@ export function registerHooks(pi: ExtensionAPI): void { }); pi.on("session_switch", async (_event, ctx) => { + initNotificationStore(process.cwd()); + installNotifyInterceptor(ctx); resetWriteGateState(); resetToolCallLoopGuard(); resetAskUserQuestionsCache(); diff --git a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts index 03156b52a..9abe3fbb8 100644 --- a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +++ b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts @@ -5,6 +5,7 @@ import type { ExtensionAPI } from "@gsd/pi-coding-agent"; import { Key } from "@gsd/pi-tui"; import { GSDDashboardOverlay } from "../dashboard-overlay.js"; +import { GSDNotificationOverlay } from "../notification-overlay.js"; import { ParallelMonitorOverlay } from "../parallel-monitor-overlay.js"; import { shortcutDesc } from "../../shared/mod.js"; @@ -31,6 +32,24 @@ export function registerShortcuts(pi: ExtensionAPI): void { }, }); + pi.registerShortcut(Key.ctrlAlt("n"), { + description: shortcutDesc("Open notification history", "/gsd notifications"), + handler: async (ctx) => { + await ctx.ui.custom( + (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()), + { + overlay: true, + overlayOptions: { + width: "80%", + minWidth: 60, + maxHeight: "88%", + anchor: "center", + }, + }, + ); + }, + }); + pi.registerShortcut(Key.ctrlAlt("p"), { description: shortcutDesc("Open parallel worker monitor", "/gsd parallel watch"), handler: async (ctx) => { diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 06b614173..a232a2001 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -48,6 +48,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" }, { cmd: "run-hook", desc: "Manually trigger a specific hook" }, { cmd: "skill-health", desc: "Skill lifecycle dashboard" }, + { cmd: "notifications", desc: "View, filter, and clear persistent notification history" }, { cmd: "doctor", desc: "Runtime health checks with auto-fix" }, { cmd: "logs", desc: "Browse activity logs, debug logs, and metrics" }, { cmd: "forensics", desc: "Examine execution logs" }, @@ -110,6 +111,11 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "keys", desc: "Manage API keys" }, { cmd: "prefs", desc: "Configure global preferences" }, ], + notifications: [ + { cmd: "clear", desc: "Clear all notifications" }, + { cmd: "tail", desc: "Show last N notifications (default: 20)" }, + { cmd: "filter", desc: "Filter by severity (error|warning|info|success)" }, + ], logs: [ { cmd: "debug", desc: "List or view debug log files" }, { cmd: "tail", desc: "Show last N activity log summaries" }, diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index c915f0486..d7adce661 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -29,6 +29,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /gsd queue Show queued/dispatched units and execution order", " /gsd history View execution history [--cost] [--phase] [--model] [N]", " /gsd changelog Show categorized release notes [version]", + " /gsd notifications View persistent notification history [clear|tail|filter] (Ctrl+Alt+N)", "", "COURSE CORRECTION", " /gsd steer Apply user override to active work", diff --git a/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts b/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts new file mode 100644 index 000000000..e66309b95 --- /dev/null +++ b/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts @@ -0,0 +1,139 @@ +// GSD Extension — /gsd notifications Command Handler +// View, filter, and clear the persistent notification history. + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { + readNotifications, + clearNotifications, + getUnreadCount, + suppressPersistence, + unsuppressPersistence, + type NotifySeverity, +} from "../../notification-store.js"; +import { GSDNotificationOverlay } from "../../notification-overlay.js"; + +function severityIcon(severity: NotifySeverity): string { + switch (severity) { + case "error": return "✗"; + case "warning": return "⚠"; + case "success": return "✓"; + case "info": + default: return "●"; + } +} + +function formatTimestamp(ts: string): string { + try { + const d = new Date(ts); + return d.toLocaleString("en-US", { hour12: false, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } catch { + return ts.slice(0, 19); + } +} + +export async function handleNotificationsCommand( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + // /gsd notifications clear + if (args === "clear") { + clearNotifications(); + // Suppress persistence so the confirmation toast doesn't re-populate the store + suppressPersistence(); + try { + ctx.ui.notify("All notifications cleared.", "success"); + } finally { + unsuppressPersistence(); + } + return true; + } + + // /gsd notifications tail [N] + if (args === "tail" || args.startsWith("tail ")) { + const countStr = args.replace(/^tail\s*/, "").trim(); + const count = countStr ? parseInt(countStr, 10) : 20; + const n = isNaN(count) || count < 1 ? 20 : Math.min(count, 100); + const entries = readNotifications().slice(0, n); + + if (entries.length === 0) { + ctx.ui.notify("No notifications.", "info"); + return true; + } + + const lines = entries.map((e) => + `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, + ); + ctx.ui.notify(`Last ${entries.length} notification(s):\n${lines.join("\n")}`, "info"); + return true; + } + + // /gsd notifications filter + if (args.startsWith("filter ")) { + const severity = args.replace(/^filter\s+/, "").trim().toLowerCase(); + if (!["error", "warning", "info", "success"].includes(severity)) { + ctx.ui.notify("Usage: /gsd notifications filter ", "warning"); + return true; + } + const entries = readNotifications().filter((e) => e.severity === severity); + + if (entries.length === 0) { + ctx.ui.notify(`No ${severity} notifications.`, "info"); + return true; + } + + const lines = entries.slice(0, 20).map((e) => + `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, + ); + const suffix = entries.length > 20 ? `\n... and ${entries.length - 20} more` : ""; + ctx.ui.notify(`${severity} notifications (${entries.length}):\n${lines.join("\n")}${suffix}`, "info"); + return true; + } + + // /gsd notifications (no args) — open overlay in TUI, or print summary + if (args === "" || args === "status") { + // Try overlay first (TUI mode) + if (ctx.hasUI) { + try { + await ctx.ui.custom( + (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()), + { + overlay: true, + overlayOptions: { + width: "80%", + minWidth: 60, + maxHeight: "88%", + anchor: "center", + }, + }, + ); + return true; + } catch { + // Fall through to text output if overlay fails + } + } + + // Text fallback (RPC/headless mode) + const unread = getUnreadCount(); + const entries = readNotifications().slice(0, 10); + if (entries.length === 0) { + ctx.ui.notify("No notifications.", "info"); + return true; + } + + const lines = entries.map((e) => + `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`, + ); + const header = unread > 0 ? `${unread} unread — ` : ""; + ctx.ui.notify(`${header}Recent notifications:\n${lines.join("\n")}`, "info"); + return true; + } + + // Unknown subcommand + ctx.ui.notify( + "Usage: /gsd notifications [clear|tail [N]|filter ]", + "warning", + ); + return true; +} diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 4ebfad1bf..532a4b4ec 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -178,6 +178,11 @@ Examples: await dispatchDirectPhase(ctx, pi, phase, projectRoot()); return true; } + if (trimmed === "notifications" || trimmed.startsWith("notifications ")) { + const { handleNotificationsCommand } = await import("./notifications-handler.js"); + await handleNotificationsCommand(trimmed.replace(/^notifications\s*/, "").trim(), ctx, pi); + return true; + } if (trimmed === "inspect") { await handleInspect(ctx); return true; diff --git a/src/resources/extensions/gsd/notification-overlay.ts b/src/resources/extensions/gsd/notification-overlay.ts new file mode 100644 index 000000000..ec25c440a --- /dev/null +++ b/src/resources/extensions/gsd/notification-overlay.ts @@ -0,0 +1,267 @@ +// GSD Extension — Notification History Overlay +// Scrollable panel showing all persisted notifications with severity filtering. +// Toggled with Ctrl+Alt+N or opened from /gsd notifications. + +import type { Theme } from "@gsd/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui"; + +import { + readNotifications, + markAllRead, + clearNotifications, + getUnreadCount, + type NotificationEntry, + type NotifySeverity, +} from "./notification-store.js"; +import { padRight, centerLine, joinColumns, formatDuration } from "../shared/mod.js"; + +type FilterMode = "all" | "error" | "warning" | "info"; +const FILTER_CYCLE: FilterMode[] = ["all", "error", "warning", "info"]; + +function severityIcon(severity: NotifySeverity): string { + switch (severity) { + case "error": return "✗"; + case "warning": return "⚠"; + case "success": return "✓"; + case "info": + default: return "●"; + } +} + +function formatTimestamp(ts: string): string { + try { + const d = new Date(ts); + const now = Date.now(); + const diffMs = now - d.getTime(); + if (diffMs < 60_000) return "just now"; + if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)}m ago`; + if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)}h ago`; + return `${Math.floor(diffMs / 86400_000)}d ago`; + } catch { + return ts.slice(11, 19); // fallback: HH:MM:SS + } +} + +export class GSDNotificationOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + private scrollOffset = 0; + private filterIndex = 0; + private entries: NotificationEntry[] = []; + private refreshTimer: ReturnType; + private disposed = false; + private resizeHandler: (() => void) | null = null; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + onClose: () => void, + ) { + this.tui = tui; + this.theme = theme; + this.onClose = onClose; + + // Mark all as read on open + markAllRead(); + this.entries = readNotifications(); + + // Resize handler + this.resizeHandler = () => { + if (this.disposed) return; + this.invalidate(); + this.tui.requestRender(); + }; + process.stdout.on("resize", this.resizeHandler); + + // Refresh every 3s for new notifications + this.refreshTimer = setInterval(() => { + if (this.disposed) return; + const fresh = readNotifications(); + if (fresh.length !== this.entries.length) { + this.entries = fresh; + markAllRead(); + this.invalidate(); + this.tui.requestRender(); + } + }, 3000); + } + + private get filter(): FilterMode { + return FILTER_CYCLE[this.filterIndex]!; + } + + private get filteredEntries(): NotificationEntry[] { + if (this.filter === "all") return this.entries; + return this.entries.filter((e) => e.severity === this.filter); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("n"))) { + this.dispose(); + this.onClose(); + return; + } + + // Scroll + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + this.scrollOffset++; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffset = Math.max(0, this.scrollOffset - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (data === "g") { + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (data === "G") { + this.scrollOffset = 999; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Filter cycle + if (data === "f") { + this.filterIndex = (this.filterIndex + 1) % FILTER_CYCLE.length; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Clear all + if (data === "c") { + clearNotifications(); + this.entries = []; + this.scrollOffset = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const content = this.buildContentLines(width); + const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24); + const chromeHeight = 2; // top + bottom border + const visibleContentRows = Math.max(1, viewportHeight - chromeHeight); + const maxScroll = Math.max(0, content.length - visibleContentRows); + this.scrollOffset = Math.min(this.scrollOffset, maxScroll); + const visibleContent = content.slice(this.scrollOffset, this.scrollOffset + visibleContentRows); + + const lines = this.wrapInBox(visibleContent, width); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedLines = undefined; + this.cachedWidth = undefined; + } + + dispose(): void { + this.disposed = true; + clearInterval(this.refreshTimer); + if (this.resizeHandler) { + process.stdout.removeListener("resize", this.resizeHandler); + this.resizeHandler = null; + } + } + + private wrapInBox(inner: string[], width: number): string[] { + const th = this.theme; + const border = (s: string) => th.fg("borderAccent", s); + const innerWidth = width - 4; + const lines: string[] = []; + + lines.push(border("╭" + "─".repeat(width - 2) + "╮")); + for (const line of inner) { + const truncated = truncateToWidth(line, innerWidth); + const padWidth = Math.max(0, innerWidth - visibleWidth(truncated)); + lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│")); + } + lines.push(border("╰" + "─".repeat(width - 2) + "╯")); + return lines; + } + + private buildContentLines(width: number): string[] { + const th = this.theme; + const shellWidth = width - 4; + const contentWidth = Math.min(shellWidth, 128); + const sidePad = Math.max(0, Math.floor((shellWidth - contentWidth) / 2)); + const leftMargin = " ".repeat(sidePad); + const lines: string[] = []; + + const row = (content = ""): string => { + const truncated = truncateToWidth(content, contentWidth); + return leftMargin + padRight(truncated, contentWidth); + }; + const blank = () => row(""); + const hr = () => row(th.fg("dim", "─".repeat(contentWidth))); + + // Header + const title = th.fg("accent", th.bold("Notifications")); + const filterLabel = this.filter === "all" + ? th.fg("dim", "all") + : th.fg(this.filter === "error" ? "error" : this.filter === "warning" ? "warning" : "dim", this.filter); + const count = `${this.filteredEntries.length} entries`; + lines.push(row(joinColumns( + `${title} ${th.fg("dim", "filter:")} ${filterLabel}`, + th.fg("dim", count), + contentWidth, + ))); + lines.push(hr()); + + // Controls + lines.push(row(th.fg("dim", "↑/↓ scroll f filter c clear Esc close"))); + lines.push(blank()); + + // Entries + const filtered = this.filteredEntries; + if (filtered.length === 0) { + lines.push(blank()); + lines.push(row(th.fg("dim", this.entries.length === 0 + ? "No notifications yet." + : `No ${this.filter} notifications.`))); + lines.push(blank()); + return lines; + } + + for (const entry of filtered) { + const icon = severityIcon(entry.severity); + const coloredIcon = entry.severity === "error" ? th.fg("error", icon) + : entry.severity === "warning" ? th.fg("warning", icon) + : entry.severity === "success" ? th.fg("success", icon) + : th.fg("dim", icon); + const time = th.fg("dim", formatTimestamp(entry.ts)); + const source = entry.source === "workflow-logger" ? th.fg("dim", " [engine]") : ""; + + // First line: icon + timestamp + source + const msgMaxWidth = contentWidth - 20; + const msg = entry.message.length > msgMaxWidth + ? entry.message.slice(0, msgMaxWidth - 1) + "…" + : entry.message; + + lines.push(row(`${coloredIcon} ${time}${source} ${msg}`)); + } + + return lines; + } +} diff --git a/src/resources/extensions/gsd/notification-store.ts b/src/resources/extensions/gsd/notification-store.ts new file mode 100644 index 000000000..54a600061 --- /dev/null +++ b/src/resources/extensions/gsd/notification-store.ts @@ -0,0 +1,288 @@ +// GSD Extension — Persistent Notification Store +// Captures all ctx.ui.notify() calls and workflow-logger warnings to +// .gsd/notifications.jsonl so they survive context resets and session restarts. +// Rotates at MAX_ENTRIES to prevent unbounded growth. + +import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +// ─── Types ────────────────────────────────────────────────────────────── + +export type NotifySeverity = "info" | "success" | "warning" | "error"; +export type NotificationSource = "notify" | "workflow-logger"; + +export interface NotificationEntry { + id: string; + ts: string; + severity: NotifySeverity; + message: string; + source: NotificationSource; + read: boolean; +} + +// ─── Constants ────────────────────────────────────────────────────────── + +const MAX_ENTRIES = 500; +const FILENAME = "notifications.jsonl"; +const LOCKFILE = "notifications.lock"; + +// ─── Module State ─────────────────────────────────────────────────────── + +let _basePath: string | null = null; +let _lineCount = 0; // Hint for rotation — not authoritative for public API +let _suppressCount = 0; + +// ─── Public API ───────────────────────────────────────────────────────── + +/** + * Initialize the notification store. Call once at session start with the + * project root. Seeds in-memory counters from the existing file on disk. + */ +export function initNotificationStore(basePath: string): void { + _basePath = basePath; + // Seed line count hint for rotation — public counters read from disk + _lineCount = _readEntriesFromDisk(basePath).length; +} + +/** + * Append a notification entry to the store. Synchronous — safe to call + * from the notify() shim which is declared void (not async). + */ +export function appendNotification( + message: string, + severity: NotifySeverity, + source: NotificationSource = "notify", +): void { + if (!_basePath) return; + if (_suppressCount > 0) return; + + const entry: NotificationEntry = { + id: randomUUID(), + ts: new Date().toISOString(), + severity, + message: message.length > 500 ? message.slice(0, 500) + "…" : message, + source, + read: false, + }; + + try { + const dir = join(_basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + appendFileSync(join(dir, FILENAME), JSON.stringify(entry) + "\n", "utf-8"); + _lineCount++; + + // Rotate if hint suggests we're over limit + if (_lineCount > MAX_ENTRIES) { + _rotate(); + } + } catch { + // Non-fatal — never let persistence break the caller + } +} + +/** + * Read all notification entries from disk. Returns newest-first. + */ +export function readNotifications(basePath?: string): NotificationEntry[] { + const bp = basePath ?? _basePath; + if (!bp) return []; + return _readEntriesFromDisk(bp).reverse(); +} + +/** + * Mark all notifications as read. Atomic rewrite via temp-file + rename. + * Resyncs in-memory counters from disk after mutation. + */ +export function markAllRead(basePath?: string): void { + const bp = basePath ?? _basePath; + if (!bp) return; + + const entries = _readEntriesFromDisk(bp); + if (entries.length === 0) return; + + const hasUnread = entries.some((e) => !e.read); + if (!hasUnread) return; + + try { + _withLock(bp, () => { + // Re-read inside lock to get freshest state + const fresh = _readEntriesFromDisk(bp); + if (fresh.length === 0 || !fresh.some((e) => !e.read)) return; + const lines = fresh.map((e) => JSON.stringify({ ...e, read: true })); + _atomicWrite(bp, lines.join("\n") + "\n"); + }); + } catch { + // Non-fatal + } +} + +/** + * Clear all notifications. Atomic write of empty content under lock. + */ +export function clearNotifications(basePath?: string): void { + const bp = basePath ?? _basePath; + if (!bp) return; + + try { + _withLock(bp, () => { + _atomicWrite(bp, ""); + }); + } catch { + // Non-fatal + } +} + +/** + * Get the current unread count. Reads from disk to stay accurate across + * processes (web subprocess can clear/modify the file independently). + */ +export function getUnreadCount(): number { + if (!_basePath) return 0; + try { + const entries = _readEntriesFromDisk(_basePath); + return entries.filter((e) => !e.read).length; + } catch { + return 0; + } +} + +/** + * Get the total notification count. Reads from disk for cross-process accuracy. + */ +export function getLineCount(): number { + if (!_basePath) return 0; + try { + return _readEntriesFromDisk(_basePath).length; + } catch { + return 0; + } +} + +/** + * Temporarily suppress persistence. Use around ctx.ui.notify calls that + * should NOT be persisted (e.g., confirmation toasts after clear). + * Calls are ref-counted — nest safely. + */ +export function suppressPersistence(): void { + _suppressCount++; +} + +export function unsuppressPersistence(): void { + _suppressCount = Math.max(0, _suppressCount - 1); +} + +// ─── Test Helpers ─────────────────────────────────────────────────────── + +/** + * Reset module state. Only for use in tests. + */ +export function _resetNotificationStore(): void { + _basePath = null; + _lineCount = 0; + _suppressCount = 0; +} + +// ─── Internal ─────────────────────────────────────────────────────────── + +function _readEntriesFromDisk(basePath: string): NotificationEntry[] { + const filePath = join(basePath, ".gsd", FILENAME); + if (!existsSync(filePath)) return []; + try { + const content = readFileSync(filePath, "utf-8"); + return content + .split("\n") + .filter((l) => l.length > 0) + .map((l) => { + try { + return JSON.parse(l) as NotificationEntry; + } catch { + return null; + } + }) + .filter((e): e is NotificationEntry => e !== null); + } catch { + return []; + } +} + +function _rotate(): void { + if (!_basePath) return; + try { + _withLock(_basePath, () => { + // Re-read inside lock to get freshest state + const entries = _readEntriesFromDisk(_basePath!); + if (entries.length <= MAX_ENTRIES) return; + const trimmed = entries.slice(entries.length - MAX_ENTRIES); + const lines = trimmed.map((e) => JSON.stringify(e)); + _atomicWrite(_basePath!, lines.join("\n") + "\n"); + }); + } catch { + // Non-fatal + } +} + +/** + * Atomic file rewrite via temp-file + rename. Prevents partial reads + * by other processes (web API subprocess, parallel workers). + * Must be called inside _withLock for cross-process safety. + */ +function _atomicWrite(basePath: string, content: string): void { + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + const target = join(dir, FILENAME); + const tmp = target + ".tmp." + process.pid; + writeFileSync(tmp, content, "utf-8"); + renameSync(tmp, target); +} + +/** + * Acquire an exclusive lockfile for rewrite operations. + * Uses O_CREAT|O_EXCL for atomic creation — if the file exists, another + * process holds the lock. Retries briefly, then proceeds anyway (best-effort) + * to avoid deadlocking the UI on a stale lock. + */ +function _withLock(basePath: string, fn: () => T): T { + const lockPath = join(basePath, ".gsd", LOCKFILE); + let fd: number | null = null; + const maxAttempts = 5; + const retryMs = 20; + + for (let i = 0; i < maxAttempts; i++) { + try { + mkdirSync(join(basePath, ".gsd"), { recursive: true }); + fd = openSync(lockPath, "wx"); + break; + } catch (err: any) { + if (err?.code === "EEXIST") { + // Check if lock is stale (older than 5s) + try { + const stat = readFileSync(lockPath, "utf-8"); + const lockTime = parseInt(stat, 10); + if (Date.now() - lockTime > 5000) { + try { unlinkSync(lockPath); } catch { /* race ok */ } + continue; + } + } catch { /* can't read lock, retry */ } + + // Wait and retry + const start = Date.now(); + while (Date.now() - start < retryMs) { /* spin */ } + continue; + } + // Other error — proceed without lock + break; + } + } + + try { + // Write our PID timestamp into the lock for stale detection + if (fd !== null) { + writeFileSync(lockPath, String(Date.now()), "utf-8"); + closeSync(fd); + } + return fn(); + } finally { + try { unlinkSync(lockPath); } catch { /* best-effort cleanup */ } + } +} diff --git a/src/resources/extensions/gsd/notification-widget.ts b/src/resources/extensions/gsd/notification-widget.ts new file mode 100644 index 000000000..15ac855e2 --- /dev/null +++ b/src/resources/extensions/gsd/notification-widget.ts @@ -0,0 +1,68 @@ +// GSD Extension — Notification Widget +// Always-on ambient widget rendered belowEditor showing unread count and +// the most recent notification message. Refreshes every 5 seconds. +// Widget key: "gsd-notifications", placement: "belowEditor" + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; + +import { getUnreadCount, readNotifications } from "./notification-store.js"; + +// ─── Pure rendering ──���────────────────────────���───────────────────────── + +export function buildNotificationWidgetLines(): string[] { + const unread = getUnreadCount(); + if (unread === 0) return []; + + const entries = readNotifications(); + const latest = entries[0]; // newest-first + if (!latest) return []; + + const icon = latest.severity === "error" ? "✗" : latest.severity === "warning" ? "⚠" : "●"; + const badge = `${unread} unread`; + const msgMax = 80; + const truncated = latest.message.length > msgMax + ? latest.message.slice(0, msgMax - 1) + "…" + : latest.message; + + return [` ${icon} [${badge}] ${truncated} (Ctrl+Alt+N to view)`]; +} + +// ─── Widget init ──────────────────────────────────────────────────────── + +const REFRESH_INTERVAL_MS = 5_000; + +/** + * Initialize the always-on notification widget (belowEditor). + * Call once from session_start after the notification store is initialized. + */ +export function initNotificationWidget(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + + // String-array fallback for RPC mode + ctx.ui.setWidget("gsd-notifications", buildNotificationWidgetLines(), { placement: "belowEditor" }); + + // Factory-based widget for TUI mode + ctx.ui.setWidget("gsd-notifications", (_tui, _theme) => { + let cachedLines: string[] | undefined; + + const refresh = () => { + cachedLines = undefined; + _tui.requestRender(); + }; + + const refreshTimer = setInterval(refresh, REFRESH_INTERVAL_MS); + + return { + render(_width: number): string[] { + if (!cachedLines) cachedLines = buildNotificationWidgetLines(); + return cachedLines; + }, + invalidate(): void { + cachedLines = undefined; + }, + dispose(): void { + clearInterval(refreshTimer); + }, + }; + }, { placement: "belowEditor" }); +} diff --git a/src/resources/extensions/gsd/tests/notification-store.test.ts b/src/resources/extensions/gsd/tests/notification-store.test.ts new file mode 100644 index 000000000..2d8dd105a --- /dev/null +++ b/src/resources/extensions/gsd/tests/notification-store.test.ts @@ -0,0 +1,249 @@ +// GSD Extension — Notification Store Tests + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + initNotificationStore, + appendNotification, + readNotifications, + markAllRead, + clearNotifications, + getUnreadCount, + getLineCount, + suppressPersistence, + unsuppressPersistence, + _resetNotificationStore, +} from "../notification-store.js"; + +describe("notification-store", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "gsd-notif-test-")); + mkdirSync(join(tmp, ".gsd"), { recursive: true }); + _resetNotificationStore(); + }); + + afterEach(() => { + _resetNotificationStore(); + rmSync(tmp, { recursive: true, force: true }); + }); + + test("appendNotification creates file and writes entry", () => { + initNotificationStore(tmp); + appendNotification("test message", "info"); + + const filePath = join(tmp, ".gsd", "notifications.jsonl"); + assert.ok(existsSync(filePath)); + + const content = readFileSync(filePath, "utf-8").trim(); + const entry = JSON.parse(content); + assert.equal(entry.message, "test message"); + assert.equal(entry.severity, "info"); + assert.equal(entry.source, "notify"); + assert.equal(entry.read, false); + assert.ok(entry.id); + assert.ok(entry.ts); + }); + + test("readNotifications returns newest-first", () => { + initNotificationStore(tmp); + appendNotification("first", "info"); + appendNotification("second", "warning"); + appendNotification("third", "error"); + + const entries = readNotifications(); + assert.equal(entries.length, 3); + assert.equal(entries[0].message, "third"); + assert.equal(entries[1].message, "second"); + assert.equal(entries[2].message, "first"); + }); + + test("getUnreadCount tracks appends", () => { + initNotificationStore(tmp); + assert.equal(getUnreadCount(), 0); + + appendNotification("msg1", "info"); + assert.equal(getUnreadCount(), 1); + + appendNotification("msg2", "warning"); + assert.equal(getUnreadCount(), 2); + }); + + test("markAllRead sets all entries to read", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + appendNotification("msg2", "warning"); + + assert.equal(getUnreadCount(), 2); + + markAllRead(); + + assert.equal(getUnreadCount(), 0); + + const entries = readNotifications(); + assert.ok(entries.every((e) => e.read === true)); + }); + + test("clearNotifications empties the file", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + appendNotification("msg2", "error"); + + assert.equal(getLineCount(), 2); + + clearNotifications(); + + assert.equal(getLineCount(), 0); + assert.equal(getUnreadCount(), 0); + assert.equal(readNotifications().length, 0); + }); + + test("rotation keeps only 500 entries", () => { + initNotificationStore(tmp); + + for (let i = 0; i < 510; i++) { + appendNotification(`msg-${i}`, "info"); + } + + const entries = readNotifications(); + assert.ok(entries.length <= 500, `Expected <= 500 entries, got ${entries.length}`); + // Most recent should be msg-509 + assert.equal(entries[0].message, "msg-509"); + }); + + test("source field is preserved", () => { + initNotificationStore(tmp); + appendNotification("from notify", "info", "notify"); + appendNotification("from logger", "warning", "workflow-logger"); + + const entries = readNotifications(); + assert.equal(entries[0].source, "workflow-logger"); + assert.equal(entries[1].source, "notify"); + }); + + test("messages are truncated at 500 chars", () => { + initNotificationStore(tmp); + const longMsg = "x".repeat(600); + appendNotification(longMsg, "info"); + + const entries = readNotifications(); + assert.ok(entries[0].message.length <= 501); // 500 + "…" + assert.ok(entries[0].message.endsWith("…")); + }); + + test("readNotifications with explicit basePath works", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + + // Read with explicit basePath + _resetNotificationStore(); + const entries = readNotifications(tmp); + assert.equal(entries.length, 1); + assert.equal(entries[0].message, "msg1"); + }); + + test("init seeds counters from existing file", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + appendNotification("msg2", "warning"); + + // Reset and re-init — should seed from disk + _resetNotificationStore(); + initNotificationStore(tmp); + + assert.equal(getLineCount(), 2); + assert.equal(getUnreadCount(), 2); + }); + + test("no-op when store not initialized", () => { + // Should not throw + appendNotification("msg", "info"); + assert.equal(readNotifications().length, 0); + assert.equal(getUnreadCount(), 0); + }); + + test("suppressPersistence prevents writes", () => { + initNotificationStore(tmp); + appendNotification("before", "info"); + assert.equal(getLineCount(), 1); + + suppressPersistence(); + appendNotification("suppressed", "info"); + assert.equal(getLineCount(), 1); // still 1 + + unsuppressPersistence(); + appendNotification("after", "info"); + assert.equal(getLineCount(), 2); // now 2 + + const entries = readNotifications(); + assert.equal(entries[0].message, "after"); + assert.equal(entries[1].message, "before"); + // "suppressed" should not appear + assert.ok(!entries.some((e) => e.message === "suppressed")); + }); + + test("suppressPersistence is ref-counted", () => { + initNotificationStore(tmp); + suppressPersistence(); + suppressPersistence(); + unsuppressPersistence(); + // Still suppressed (one suppress remaining) + appendNotification("still suppressed", "info"); + assert.equal(getLineCount(), 0); + + unsuppressPersistence(); + appendNotification("now works", "info"); + assert.equal(getLineCount(), 1); + }); + + test("reinit switches to new project path", () => { + const tmp2 = mkdtempSync(join(tmpdir(), "gsd-notif-test2-")); + mkdirSync(join(tmp2, ".gsd"), { recursive: true }); + + initNotificationStore(tmp); + appendNotification("project1", "info"); + + // Switch to new project + initNotificationStore(tmp2); + appendNotification("project2", "info"); + + // project2 should only have its own entry + const entries = readNotifications(); + assert.equal(entries.length, 1); + assert.equal(entries[0].message, "project2"); + + // project1 should still have its entry + const p1Entries = readNotifications(tmp); + assert.equal(p1Entries.length, 1); + assert.equal(p1Entries[0].message, "project1"); + + rmSync(tmp2, { recursive: true, force: true }); + }); + + test("counters resync from disk after markAllRead", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + appendNotification("msg2", "info"); + assert.equal(getUnreadCount(), 2); + assert.equal(getLineCount(), 2); + + markAllRead(); + assert.equal(getUnreadCount(), 0); + assert.equal(getLineCount(), 2); // entries still exist, just marked read + }); + + test("counters resync from disk after clearNotifications", () => { + initNotificationStore(tmp); + appendNotification("msg1", "info"); + appendNotification("msg2", "info"); + + clearNotifications(); + assert.equal(getUnreadCount(), 0); + assert.equal(getLineCount(), 0); + }); +}); diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index 3e135ab5c..77960f7ca 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -19,6 +19,8 @@ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { appendNotification } from "./notification-store.js"; + // ─── Types ────────────────────────────────────────────────────────────── export type LogSeverity = "warn" | "error"; @@ -245,6 +247,17 @@ function _push( const ctxStr = context ? ` ${JSON.stringify(context)}` : ""; process.stderr.write(`[gsd:${component}] ${prefix}: ${message}${ctxStr}\n`); + // Persist to notification store (both warnings and errors) + try { + appendNotification( + `[${component}] ${message}`, + severity === "error" ? "error" : "warning", + "workflow-logger", + ); + } catch (notifErr) { + process.stderr.write(`[gsd:workflow-logger] notification-store append failed: ${(notifErr as Error).message}\n`); + } + // Buffer for auto-loop to drain _buffer.push(entry); if (_buffer.length > MAX_BUFFER) { diff --git a/src/web/notifications-service.ts b/src/web/notifications-service.ts new file mode 100644 index 000000000..5253d8a77 --- /dev/null +++ b/src/web/notifications-service.ts @@ -0,0 +1,143 @@ +// GSD Web — Notifications Service +// Loads notification data via a child process that imports the notification store. + +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { join } from "node:path" +import { pathToFileURL } from "node:url" + +import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" + +export interface NotificationsData { + entries: Array<{ + id: string + ts: string + severity: string + message: string + source: string + read: boolean + }> + unreadCount: number + totalCount: number +} + +const NOTIFICATIONS_MAX_BUFFER = 2 * 1024 * 1024 +const NOTIFICATIONS_MODULE_ENV = "GSD_NOTIFICATIONS_MODULE" + +function resolveTsLoaderPath(packageRoot: string): string { + return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") +} + +export async function collectNotificationsData(projectCwdOverride?: string): Promise { + const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride) + const { packageRoot, projectCwd } = config + + const resolveTsLoader = resolveTsLoaderPath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts") + const modulePath = moduleResolution.modulePath + + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(modulePath))) { + throw new Error( + `notifications data provider not found; checked=${resolveTsLoader},${modulePath}`, + ) + } + if (moduleResolution.useCompiledJs && !existsSync(modulePath)) { + throw new Error(`notifications data provider not found; checked=${modulePath}`) + } + + const script = [ + 'const { pathToFileURL } = await import("node:url");', + `const mod = await import(pathToFileURL(process.env.${NOTIFICATIONS_MODULE_ENV}).href);`, + 'const basePath = process.env.GSD_NOTIFICATIONS_BASE;', + 'const entries = mod.readNotifications(basePath);', + 'const unread = entries.filter(e => !e.read).length;', + 'const result = { entries, unreadCount: unread, totalCount: entries.length };', + 'process.stdout.write(JSON.stringify(result));', + ].join(" ") + + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + + return await new Promise((resolveResult, reject) => { + execFile( + process.execPath, + [ + ...prefixArgs, + "--eval", + script, + ], + { + cwd: packageRoot, + env: { + ...process.env, + [NOTIFICATIONS_MODULE_ENV]: modulePath, + GSD_NOTIFICATIONS_BASE: projectCwd, + }, + maxBuffer: NOTIFICATIONS_MAX_BUFFER, + timeout: 10_000, + }, + (err, stdout, stderr) => { + if (err) { + reject(new Error(`notifications subprocess failed: ${err.message}${stderr ? `\nstderr: ${stderr}` : ""}`)) + return + } + try { + const parsed = JSON.parse(stdout) as NotificationsData + resolveResult(parsed) + } catch (parseErr) { + reject(new Error(`Failed to parse notifications output: ${(parseErr as Error).message}`)) + } + }, + ) + }) +} + +export async function clearNotificationsData(projectCwdOverride?: string): Promise { + const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride) + const { packageRoot, projectCwd } = config + + const resolveTsLoader = resolveTsLoaderPath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/notification-store.ts") + const modulePath = moduleResolution.modulePath + + if (moduleResolution.useCompiledJs && !existsSync(modulePath)) { + throw new Error(`notifications data provider not found; checked=${modulePath}`) + } + + const script = [ + 'const { pathToFileURL } = await import("node:url");', + `const mod = await import(pathToFileURL(process.env.${NOTIFICATIONS_MODULE_ENV}).href);`, + 'mod.clearNotifications(process.env.GSD_NOTIFICATIONS_BASE);', + 'process.stdout.write("ok");', + ].join(" ") + + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + + return await new Promise((resolveResult, reject) => { + execFile( + process.execPath, + [ + ...prefixArgs, + "--eval", + script, + ], + { + cwd: packageRoot, + env: { + ...process.env, + [NOTIFICATIONS_MODULE_ENV]: modulePath, + GSD_NOTIFICATIONS_BASE: projectCwd, + }, + maxBuffer: NOTIFICATIONS_MAX_BUFFER, + timeout: 10_000, + }, + (err, _stdout, stderr) => { + if (err) { + reject(new Error(`clear notifications subprocess failed: ${err.message}${stderr ? `\nstderr: ${stderr}` : ""}`)) + return + } + resolveResult() + }, + ) + }) +} diff --git a/web/app/api/notifications/route.ts b/web/app/api/notifications/route.ts new file mode 100644 index 000000000..1754517fd --- /dev/null +++ b/web/app/api/notifications/route.ts @@ -0,0 +1,49 @@ +import { collectNotificationsData, clearNotificationsData } from "../../../../src/web/notifications-service.ts" +import { requireProjectCwd } from "../../../../src/web/bridge-service.ts" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET(request: Request): Promise { + try { + const projectCwd = requireProjectCwd(request); + const url = new URL(request.url) + const countOnly = url.searchParams.get("countOnly") === "true" + + const payload = await collectNotificationsData(projectCwd) + + if (countOnly) { + return Response.json( + { unreadCount: payload.unreadCount }, + { headers: { "Cache-Control": "no-store" } }, + ) + } + + return Response.json(payload, { + headers: { "Cache-Control": "no-store" }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return Response.json( + { error: message }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ) + } +} + +export async function DELETE(request: Request): Promise { + try { + const projectCwd = requireProjectCwd(request); + await clearNotificationsData(projectCwd) + return Response.json( + { ok: true }, + { headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return Response.json( + { error: message }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ) + } +}