feat(gsd): persistent notification panel with TUI overlay, widget, and web API

Notifications from ctx.ui.notify() and workflow-logger now persist to
.gsd/notifications.jsonl instead of evaporating as transient toasts.

- notification-store: JSONL persistence with 500-entry rotation, atomic
  temp+rename rewrites, ref-counted suppress API, disk-synced counters
- notify-interceptor: WeakSet-guarded monkey-patch on ctx.ui.notify
  installed at session_start and session_switch
- notification-widget: always-on belowEditor strip showing unread count
- notification-overlay: scrollable Ctrl+Alt+N panel with severity filter
- /gsd notifications command: clear, tail, filter subcommands
- workflow-logger: warnings now also persist to notification store
- web API: GET/DELETE /api/notifications with ?countOnly support
- 16 unit tests covering store, suppress, project isolation, resync
This commit is contained in:
Jeremy 2026-04-05 21:10:32 -05:00
parent f6a1549edd
commit 8078755e4b
14 changed files with 1290 additions and 1 deletions

View file

@ -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<object>();
/**
* 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);
}

View file

@ -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<void> {
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();

View file

@ -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<void>(
(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) => {

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
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" },

View file

@ -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 <desc> Apply user override to active work",

View file

@ -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<boolean> {
// /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 <severity>
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 <error|warning|info|success>", "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<void>(
(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 <severity>]",
"warning",
);
return true;
}

View file

@ -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;

View file

@ -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<typeof setInterval>;
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;
}
}

View file

@ -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<T>(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 */ }
}
}

View file

@ -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 ──<E29480><E29480><EFBFBD>────────────────────────<E29480><E29480><EFBFBD>─────────────────────────
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" });
}

View file

@ -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);
});
});

View file

@ -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) {

View file

@ -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<NotificationsData> {
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<NotificationsData>((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<void> {
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<void>((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()
},
)
})
}

View file

@ -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<Response> {
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<Response> {
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" } },
)
}
}