aidevops 3.29.5 → 3.29.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ The result: an AI operations platform that manages projects across every busines
58
58
  [![Copyright](https://img.shields.io/badge/Copyright-Marcus%20Quinn%202025--2026-blue.svg)](https://github.com/marcusquinn)
59
59
 
60
60
  <!-- Release & Version Info -->
61
- [![Version](https://img.shields.io/badge/Version-3.29.5-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.29.7-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
62
62
  [![npm version](https://img.shields.io/npm/v/aidevops)](https://www.npmjs.com/package/aidevops)
63
63
  [![Homebrew](https://img.shields.io/badge/homebrew-marcusquinn%2Ftap-orange)](https://github.com/marcusquinn/homebrew-tap)
64
64
  [![GitHub repository](https://img.shields.io/badge/github-repository-181717.svg?logo=github)](https://github.com/marcusquinn/aidevops)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.5
1
+ 3.29.7
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.29.5
8
+ # Version: 3.29.7
9
9
 
10
10
  set -euo pipefail
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.29.5",
3
+ "version": "3.29.7",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -20,6 +20,7 @@ import {
20
20
  type GuiSetupTargetSummary,
21
21
  type GuiStatusData,
22
22
  type GuiVaultStatusData,
23
+ buildStatusNotifications,
23
24
  statusFixture,
24
25
  VAULT_STATUS_ROUTE_MANIFEST,
25
26
  } from "../../gui-shared/src";
@@ -60,6 +61,7 @@ export function readStatus(
60
61
  const reposPathRef = "~/.config/aidevops/repos.json";
61
62
  const oauthPoolPathRef = "~/.aidevops/oauth-pool.json";
62
63
  const opencodeDbPathRef = "~/.local/share/opencode/opencode.db";
64
+ const greetingCachePathRef = "~/.aidevops/cache/session-greeting.txt";
63
65
  const agentsPathRef = "~/.aidevops/agents";
64
66
  const vaultPathRef = statusFixture.vault.path_ref;
65
67
  const settingsPath = expandHome(settingsPathRef);
@@ -76,13 +78,22 @@ export function readStatus(
76
78
  const managedApps = readManagedApps(aidevopsVersion || "unknown", installedVersion || "unknown");
77
79
  const localRepos = readLocalReposSetupSummary(reposPath);
78
80
  const opencodeSessions = readOpenCodeSessions(opencodeDbPath, opencodeDbPathRef, localRepos.repos);
81
+ const oauthPool = readOAuthPoolSummary(oauthPoolPath, oauthPoolPathRef);
79
82
  const vault = readVaultSummary(repoRoot);
83
+ const notifications = buildStatusNotifications({
84
+ aiApps,
85
+ greetingOutput: readOptionalText(expandHome(greetingCachePathRef)) ?? "",
86
+ oauthPool,
87
+ restartRequired,
88
+ setupTargets,
89
+ });
80
90
  const sourcePathRefs = [
81
91
  agentsPathRef,
82
92
  settingsPathRef,
83
93
  reposPathRef,
84
94
  oauthPoolPathRef,
85
95
  opencodeDbPathRef,
96
+ greetingCachePathRef,
86
97
  vaultPathRef,
87
98
  "VERSION",
88
99
  ...setupTargets.map((target) => target.path_ref),
@@ -131,10 +142,11 @@ export function readStatus(
131
142
  repos: readRepoRegistrySummary(reposPath, reposPathRef),
132
143
  local_repos: localRepos,
133
144
  opencode_sessions: opencodeSessions,
134
- oauth_pool: readOAuthPoolSummary(oauthPoolPath, oauthPoolPathRef),
145
+ oauth_pool: oauthPool,
135
146
  setup_targets: setupTargets,
136
147
  ai_apps: aiApps,
137
148
  managed_apps: managedApps,
149
+ notifications,
138
150
  vault,
139
151
  };
140
152
 
@@ -339,6 +339,31 @@ export interface GuiCapabilitySummary {
339
339
  doc_ref: string;
340
340
  }
341
341
 
342
+ export type GuiNotificationSeverity = "success" | "info" | "warning" | "error";
343
+
344
+ export type GuiNotificationCategory = "security" | "maintenance" | "release" | "runtime" | "setup";
345
+
346
+ export interface GuiNotificationAction {
347
+ id: string;
348
+ label: string;
349
+ kind: "surface" | "command";
350
+ surface_id?: string;
351
+ command_preview?: string;
352
+ enabled: boolean;
353
+ }
354
+
355
+ export interface GuiNotificationSummary {
356
+ id: string;
357
+ title: string;
358
+ message: string;
359
+ severity: GuiNotificationSeverity;
360
+ category: GuiNotificationCategory;
361
+ source: "opencode-toast" | "gui-status";
362
+ source_ref: string;
363
+ status: "active" | "resolved";
364
+ actions: GuiNotificationAction[];
365
+ }
366
+
342
367
  export interface GuiMachineSummary {
343
368
  id: string;
344
369
  label: string;
@@ -451,6 +476,7 @@ export interface GuiStatusData {
451
476
  setup_targets: GuiSetupTargetSummary[];
452
477
  ai_apps: GuiAiAppSummary[];
453
478
  managed_apps: GuiManagedAppSummary[];
479
+ notifications: GuiNotificationSummary[];
454
480
  vault: GuiVaultStatusData;
455
481
  capabilities: GuiCapabilitySummary[];
456
482
  secrets: GuiSecretReference[];
@@ -163,6 +163,19 @@ export const statusFixture: GuiStatusData = {
163
163
  },
164
164
  ],
165
165
  managed_apps: [],
166
+ notifications: [
167
+ {
168
+ id: "fixture-security-active",
169
+ title: "Security protections active",
170
+ message: "Security: all protections active",
171
+ severity: "success",
172
+ category: "security",
173
+ source: "opencode-toast",
174
+ source_ref: "~/.aidevops/cache/session-greeting.txt",
175
+ status: "resolved",
176
+ actions: [{ id: "open-security", label: "Open security", kind: "surface", surface_id: "security", enabled: true }],
177
+ },
178
+ ],
166
179
  vault: {
167
180
  status: "uninitialized",
168
181
  setup_state: "uninitialized",
@@ -1,2 +1,3 @@
1
1
  export * from "./contracts";
2
2
  export * from "./fixtures";
3
+ export * from "./notifications";
@@ -0,0 +1,220 @@
1
+ import type { GuiAiAppSummary, GuiNotificationAction, GuiNotificationCategory, GuiNotificationSeverity, GuiNotificationSummary, GuiOAuthPoolSummary, GuiSetupTargetSummary } from "./contracts";
2
+
3
+ interface BuildNotificationInput {
4
+ aiApps: GuiAiAppSummary[];
5
+ greetingOutput: string;
6
+ oauthPool: GuiOAuthPoolSummary;
7
+ restartRequired: boolean;
8
+ setupTargets: GuiSetupTargetSummary[];
9
+ }
10
+
11
+ const toastSeverityRank: Record<GuiNotificationSeverity, number> = {
12
+ success: 0,
13
+ info: 1,
14
+ warning: 2,
15
+ error: 3,
16
+ };
17
+
18
+ const warningLineMatchers: Array<(line: string) => boolean> = [
19
+ (line) => line.startsWith("Pulse stalled"),
20
+ (line) => /contribution\(s\) need/i.test(line),
21
+ (line) => line.startsWith("[OPENCODE MAINTENANCE]"),
22
+ (line) => line.startsWith("[WARNING]"),
23
+ (line) => line.startsWith("[WARN]"),
24
+ ];
25
+
26
+ const toastTitleRules: Array<{ matches: (line: string) => boolean; title: string }> = [
27
+ { matches: (line) => line.startsWith("[SECURITY ADVISORY]"), title: "Security advisory" },
28
+ { matches: (line) => line.startsWith("[ERROR]"), title: "Startup error" },
29
+ { matches: (line) => line.startsWith("[OPENCODE MAINTENANCE]"), title: "OpenCode maintenance" },
30
+ { matches: (line) => line.startsWith("Pulse stalled"), title: "Pulse stalled" },
31
+ { matches: (line) => /contribution\(s\) need/i.test(line), title: "External contribution review" },
32
+ { matches: (line) => line.startsWith("[WARNING]") || line.startsWith("[WARN]"), title: "Startup warning" },
33
+ { matches: (line) => line.startsWith("Security: all protections active"), title: "Security protections active" },
34
+ ];
35
+
36
+ export function classifyToastLine(line: string): GuiNotificationSeverity | null {
37
+ const normalized = line.trim();
38
+ if (normalized.length === 0 || normalized.startsWith("UPDATE_AVAILABLE|") || normalized === "AUTO_UPDATE_ENABLED") {
39
+ return null;
40
+ }
41
+
42
+ if (normalized.startsWith("[SECURITY ADVISORY]") || normalized.startsWith("[ERROR]")) {
43
+ return "error";
44
+ }
45
+
46
+ if (warningLineMatchers.some((matches) => matches(normalized))) {
47
+ return "warning";
48
+ }
49
+
50
+ if (normalized.startsWith("Security: all protections active")) {
51
+ return "success";
52
+ }
53
+
54
+ return "info";
55
+ }
56
+
57
+ export function buildStatusNotifications(input: BuildNotificationInput): GuiNotificationSummary[] {
58
+ const notifications = [
59
+ ...buildToastNotifications(input.greetingOutput),
60
+ ...buildGuiStateNotifications(input),
61
+ ];
62
+
63
+ return dedupeNotifications(notifications).sort((left, right) => {
64
+ const severityDelta = toastSeverityRank[right.severity] - toastSeverityRank[left.severity];
65
+ return severityDelta === 0 ? left.title.localeCompare(right.title) : severityDelta;
66
+ });
67
+ }
68
+
69
+ function buildToastNotifications(greetingOutput: string): GuiNotificationSummary[] {
70
+ return greetingOutput
71
+ .split("\n")
72
+ .map((line) => line.trim())
73
+ .filter(Boolean)
74
+ .flatMap((line) => {
75
+ const severity = classifyToastLine(line);
76
+ if (severity === null) {
77
+ return [];
78
+ }
79
+
80
+ const category = categoryForToastLine(line, severity);
81
+ return [notification({
82
+ id: `toast-${stableId(line)}`,
83
+ title: titleForToastLine(line, severity),
84
+ message: line,
85
+ severity,
86
+ category,
87
+ source: "opencode-toast",
88
+ source_ref: "~/.aidevops/cache/session-greeting.txt",
89
+ status: severity === "success" || severity === "info" ? "resolved" : "active",
90
+ actions: actionsForToastLine(line, category, severity),
91
+ })];
92
+ });
93
+ }
94
+
95
+ function buildGuiStateNotifications(input: BuildNotificationInput): GuiNotificationSummary[] {
96
+ const setupNeedsUpdate = input.setupTargets.filter((target) => target.needs_update);
97
+ const appNeedsUpdate = input.aiApps.filter((app) => app.needs_update);
98
+ const authErrors = input.oauthPool.providers.filter((provider) => provider.auth_errors > 0);
99
+ const rateLimited = input.oauthPool.providers.filter((provider) => provider.rate_limited > 0);
100
+ const notifications: GuiNotificationSummary[] = [];
101
+
102
+ if (input.restartRequired) {
103
+ notifications.push(notification({
104
+ id: "gui-restart-required",
105
+ title: "Restart required",
106
+ message: "aidevops has updated in the background. Restart the GUI app to use the latest installed version.",
107
+ severity: "warning",
108
+ category: "release",
109
+ source: "gui-status",
110
+ source_ref: "VERSION and ~/.aidevops/agents/VERSION",
111
+ status: "active",
112
+ actions: [surfaceAction("open-installation", "Open installation", "installation"), commandAction("restart-gui", "Restart GUI app", "Quit and reopen the aidevops GUI app")],
113
+ }));
114
+ }
115
+
116
+ if (setupNeedsUpdate.length > 0 || appNeedsUpdate.length > 0) {
117
+ notifications.push(notification({
118
+ id: "gui-targets-need-update",
119
+ title: "Installed targets need update",
120
+ message: `${setupNeedsUpdate.length} setup target(s) and ${appNeedsUpdate.length} AI app target(s) need the current aidevops files.`,
121
+ severity: "warning",
122
+ category: "setup",
123
+ source: "gui-status",
124
+ source_ref: "setup_targets and ai_apps",
125
+ status: "active",
126
+ actions: [surfaceAction("open-installation", "Open installation", "installation"), commandAction("run-update", "Run update", "aidevops update")],
127
+ }));
128
+ }
129
+
130
+ for (const provider of authErrors) {
131
+ notifications.push(notification({
132
+ id: `gui-provider-auth-${provider.provider}`,
133
+ title: `${provider.provider} auth attention`,
134
+ message: `${provider.auth_errors} ${provider.provider} account(s) report auth errors. Token values remain hidden from the GUI payload.`,
135
+ severity: "error",
136
+ category: "runtime",
137
+ source: "gui-status",
138
+ source_ref: input.oauthPool.path_ref,
139
+ status: "active",
140
+ actions: [surfaceAction("open-ai-providers", "Open AI providers", "aiProviders"), commandAction("check-oauth-pool", "Check provider pool", `aidevops oauth-pool check ${provider.provider}`)],
141
+ }));
142
+ }
143
+
144
+ for (const provider of rateLimited) {
145
+ notifications.push(notification({
146
+ id: `gui-provider-rate-limit-${provider.provider}`,
147
+ title: `${provider.provider} rate limit`,
148
+ message: `${provider.rate_limited} ${provider.provider} account(s) are cooling down. The pool should recover automatically when cooldowns expire.`,
149
+ severity: "warning",
150
+ category: "runtime",
151
+ source: "gui-status",
152
+ source_ref: input.oauthPool.path_ref,
153
+ status: "active",
154
+ actions: [surfaceAction("open-ai-providers", "Open AI providers", "aiProviders")],
155
+ }));
156
+ }
157
+
158
+ return notifications;
159
+ }
160
+
161
+ function notification(input: GuiNotificationSummary): GuiNotificationSummary {
162
+ return input;
163
+ }
164
+
165
+ function titleForToastLine(line: string, severity: GuiNotificationSeverity): string {
166
+ const rule = toastTitleRules.find((candidate) => candidate.matches(line));
167
+ return rule?.title ?? (severity === "info" ? "Runtime status" : "aidevops status");
168
+ }
169
+
170
+ function categoryForToastLine(line: string, severity: GuiNotificationSeverity): GuiNotificationCategory {
171
+ if (line.startsWith("[SECURITY ADVISORY]") || line.startsWith("Security:")) return "security";
172
+ if (line.startsWith("[OPENCODE MAINTENANCE]") || line.startsWith("Pulse stalled")) return "maintenance";
173
+ if (/update|version|running in/i.test(line)) return "release";
174
+ if (severity === "warning") return "setup";
175
+ return "runtime";
176
+ }
177
+
178
+ function actionsForToastLine(line: string, category: GuiNotificationCategory, severity: GuiNotificationSeverity): GuiNotificationAction[] {
179
+ if (category === "security") {
180
+ return [surfaceAction("open-security", "Open security", "security"), surfaceAction("open-vault", "Open Vault", "vault")];
181
+ }
182
+
183
+ if (line.startsWith("[OPENCODE MAINTENANCE]")) {
184
+ return [surfaceAction("open-local-setup", "Open local setup", "localSetup"), commandAction("run-maintenance", "Run maintenance", "opencode-db-maintenance-helper.sh run")];
185
+ }
186
+
187
+ if (line.startsWith("Pulse stalled")) {
188
+ return [surfaceAction("open-maintenance", "Open maintenance", "maintenance")];
189
+ }
190
+
191
+ if (severity === "warning") {
192
+ return [surfaceAction("open-installation", "Open installation", "installation"), commandAction("run-update", "Run update", "aidevops update")];
193
+ }
194
+
195
+ return [surfaceAction("open-overview", "Open overview", "overview")];
196
+ }
197
+
198
+ function surfaceAction(id: string, label: string, surfaceId: string): GuiNotificationAction {
199
+ return { id, label, kind: "surface", surface_id: surfaceId, enabled: true };
200
+ }
201
+
202
+ function commandAction(id: string, label: string, commandPreview: string): GuiNotificationAction {
203
+ return { id, label, kind: "command", command_preview: commandPreview, enabled: false };
204
+ }
205
+
206
+ function stableId(value: string): string {
207
+ let hash = 0;
208
+ for (let index = 0; index < value.length; index += 1) {
209
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
210
+ }
211
+ return Math.abs(hash).toString(36);
212
+ }
213
+
214
+ function dedupeNotifications(notifications: GuiNotificationSummary[]): GuiNotificationSummary[] {
215
+ const byId = new Map<string, GuiNotificationSummary>();
216
+ for (const item of notifications) {
217
+ byId.set(item.id, item);
218
+ }
219
+ return [...byId.values()];
220
+ }
@@ -1,10 +1,10 @@
1
1
  /* jshint esversion: 11 */
2
2
  import { type ReactElement, type ReactNode, useEffect, useState } from "react";
3
- import { FiBell, FiChevronLeft, FiChevronRight, FiCommand, FiGlobe, FiHash, FiHelpCircle, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiTerminal, FiUser } from "react-icons/fi";
4
- import type { GuiFileRootId, GuiStatusData } from "../../gui-shared/src";
3
+ import { FiAlertTriangle, FiBell, FiCheckCircle, FiChevronLeft, FiChevronRight, FiCommand, FiGlobe, FiHash, FiHelpCircle, FiInfo, FiLogOut, FiMessageSquare, FiSearch, FiSettings, FiShield, FiTerminal, FiTool, FiUser } from "react-icons/fi";
4
+ import type { GuiFileRootId, GuiNotificationSummary, GuiStatusData } from "../../gui-shared/src";
5
5
  import { SurfaceGlyph } from "./AppNavigation";
6
6
  import type { ConversationMode, ShellMode, SurfaceId, SurfaceNavItem } from "./app-model";
7
- import { inventorySurfaceConfigs, text } from "./app-model";
7
+ import { inventorySurfaceConfigs, surfaceIds, text } from "./app-model";
8
8
  import { CommandPalette, type CommandPaletteSelection, commandPaletteShortcutQuery } from "./CommandPalette";
9
9
  import { FileExplorerSurface } from "./FileExplorerSurface";
10
10
  import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
@@ -42,7 +42,7 @@ export function Workspace({ activeItem, activeSectionLabel, activeSurface, canGo
42
42
  <div className="workspace-scroll">
43
43
  {shellMode === "sessions"
44
44
  ? <ConversationWorkspace conversationMode={conversationMode} selectedLocalRepoIndex={selectedLocalRepoIndex} selectedSessionId={selectedSessionId} status={status} />
45
- : <SurfaceContent activeItem={activeItem} activeSurface={activeSurface} fileRoot={fileRoot} status={status} />}
45
+ : <SurfaceContent activeItem={activeItem} activeSurface={activeSurface} fileRoot={fileRoot} openSurface={setActiveSurface} status={status} />}
46
46
  </div>
47
47
  </section>
48
48
  );
@@ -70,6 +70,7 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
70
70
  const [profileOpen, setProfileOpen] = useState(false);
71
71
  const userInitials = status.machine.initials || "AI";
72
72
  const userName = status.machine.username || "Local user";
73
+ const activeNotifications = status.notifications.filter((notification) => notification.status === "active");
73
74
 
74
75
  useEffect(() => {
75
76
  const openCommandPalette = (event: KeyboardEvent) => {
@@ -134,9 +135,9 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
134
135
  </button>
135
136
  <button aria-expanded={notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={() => { setNotificationsOpen((current) => !current); setProfileOpen(false); }} type="button">
136
137
  <FiBell aria-hidden="true" />
137
- <span className="notification-dot" aria-hidden="true" />
138
+ {activeNotifications.length > 0 ? <span className="notification-dot" aria-hidden="true" /> : null}
138
139
  </button>
139
- {notificationsOpen ? <NotificationsMenu openSurface={(surface) => openItem({ surface })} /> : null}
140
+ {notificationsOpen ? <NotificationsMenu notifications={status.notifications} openSurface={(surface) => openItem({ surface })} /> : null}
140
141
  </div>
141
142
  <button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={() => setAssistantOpen((current) => !current)} title="AI sessions (_)" type="button">
142
143
  <FiTerminal aria-hidden="true" />
@@ -176,16 +177,35 @@ function isEditableShortcutTarget(target: EventTarget | null): boolean {
176
177
  return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
177
178
  }
178
179
 
179
- function NotificationsMenu({ openSurface }: { openSurface: (surface: SurfaceId) => void }): ReactElement {
180
+ function NotificationsMenu({ notifications, openSurface }: { notifications: GuiNotificationSummary[]; openSurface: (surface: SurfaceId) => void }): ReactElement {
181
+ const active = notifications.filter((notification) => notification.status === "active");
182
+ const preview = notifications.slice(0, 4);
183
+
180
184
  return (
181
185
  <div className="popover-menu notifications-menu" role="menu">
182
- <strong>Notifications</strong>
183
- <p>Local readiness updates, release activity, and account alerts will collect here.</p>
186
+ <div className="notifications-menu-heading">
187
+ <strong>Notifications</strong>
188
+ <span>{active.length} active</span>
189
+ </div>
190
+ {preview.length === 0 ? <p>No current aidevops notifications.</p> : null}
191
+ {preview.map((notification) => <NotificationPreview key={notification.id} notification={notification} />)}
184
192
  <button onClick={() => openSurface("notifications")} role="menuitem" type="button">Open notifications</button>
185
193
  </div>
186
194
  );
187
195
  }
188
196
 
197
+ function NotificationPreview({ notification }: { notification: GuiNotificationSummary }): ReactElement {
198
+ return (
199
+ <article className={`notification-preview ${notification.severity}`}>
200
+ <span aria-hidden="true"><NotificationIcon notification={notification} /></span>
201
+ <div>
202
+ <strong>{notification.title}</strong>
203
+ <small>{notification.category} · {notification.status}</small>
204
+ </div>
205
+ </article>
206
+ );
207
+ }
208
+
189
209
  function ConversationWorkspace({ conversationMode, selectedLocalRepoIndex, selectedSessionId, status }: {
190
210
  conversationMode: ConversationMode;
191
211
  selectedLocalRepoIndex: number;
@@ -268,10 +288,11 @@ function AssistantPanel({ activeSurface, userName }: { activeSurface: SurfaceId;
268
288
  );
269
289
  }
270
290
 
271
- function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
291
+ function SurfaceContent({ activeItem, activeSurface, fileRoot, openSurface, status }: {
272
292
  activeItem: SurfaceNavItem;
273
293
  activeSurface: SurfaceId;
274
294
  fileRoot: GuiFileRootId | undefined;
295
+ openSurface: (surface: SurfaceId) => void;
275
296
  status: GuiStatusData;
276
297
  }) {
277
298
  const inventoryConfig = inventorySurfaceConfigs[activeSurface];
@@ -280,7 +301,7 @@ function SurfaceContent({ activeItem, activeSurface, fileRoot, status }: {
280
301
  overview: <OverviewSurface status={status} />,
281
302
  help: <HelpSurface />,
282
303
  settings: <SettingsSurface status={status} />,
283
- notifications: <NotificationsSurface />,
304
+ notifications: <NotificationsSurface openSurface={openSurface} status={status} />,
284
305
  admin: <AdminSurface />,
285
306
  vault: <VaultSurface status={status} />,
286
307
  aiSessions: <PlannedSurface label={text.aiSessions} detail={text.aiSessionsIntro} />,
@@ -391,22 +412,69 @@ function SettingsSurface({ status }: { status: GuiStatusData }): ReactElement {
391
412
  );
392
413
  }
393
414
 
394
- function NotificationsSurface(): ReactElement {
415
+ function NotificationsSurface({ openSurface, status }: { openSurface: (surface: SurfaceId) => void; status: GuiStatusData }): ReactElement {
416
+ const active = status.notifications.filter((notification) => notification.status === "active");
417
+ const resolved = status.notifications.filter((notification) => notification.status === "resolved");
418
+
395
419
  return (
396
- <section className="settings-surface">
397
- <div className="planned-card">
420
+ <section className="settings-surface notifications-surface">
421
+ <div className="planned-card notifications-hero">
398
422
  <h2>Notifications</h2>
399
- <p>Release updates, workflow failures, security notices, and hosted account messages will appear here.</p>
423
+ <p>OpenCode startup toasts and the GUI notification centre read the same aidevops status cache and local readiness data, so resolved items disappear or downgrade everywhere after the underlying status changes.</p>
424
+ <section aria-label="Notification summary" className="notification-summary-strip">
425
+ <span><strong>{active.length}</strong> active</span>
426
+ <span><strong>{resolved.length}</strong> resolved</span>
427
+ <span><strong>{status.notifications.length}</strong> total</span>
428
+ </section>
400
429
  </div>
401
- <ul className="notification-list">
402
- <li><strong>Readiness</strong><span>Local status and restart notices.</span></li>
403
- <li><strong>Workflow</strong><span>PR, release, and routine activity.</span></li>
404
- <li><strong>Security</strong><span>Vault and credentials posture reminders.</span></li>
430
+ <ul aria-label="aidevops notifications" className="github-notification-list">
431
+ {status.notifications.length === 0 ? <p className="empty-state">No aidevops notifications are currently reported.</p> : null}
432
+ {status.notifications.map((notification) => <NotificationCard key={notification.id} notification={notification} openSurface={openSurface} />)}
405
433
  </ul>
406
434
  </section>
407
435
  );
408
436
  }
409
437
 
438
+ function NotificationCard({ notification, openSurface }: { notification: GuiNotificationSummary; openSurface: (surface: SurfaceId) => void }): ReactElement {
439
+ return (
440
+ <li className={`github-notification-card ${notification.severity} ${notification.status}`}>
441
+ <div className="notification-state-icon" aria-hidden="true"><NotificationIcon notification={notification} /></div>
442
+ <div className="notification-card-body">
443
+ <header>
444
+ <div>
445
+ <p className="eyebrow">{notification.category} · {notification.source}</p>
446
+ <h3>{notification.title}</h3>
447
+ </div>
448
+ <span className="notification-status-pill">{notification.status}</span>
449
+ </header>
450
+ <p>{notification.message}</p>
451
+ <small>{notification.source_ref}</small>
452
+ <div className="notification-actions">
453
+ {notification.actions.map((action) => {
454
+ if (action.kind === "surface" && isSurfaceId(action.surface_id)) {
455
+ const surfaceId = action.surface_id;
456
+ return <button className="secondary-action" disabled={!action.enabled} key={action.id} onClick={() => openSurface(surfaceId)} type="button">{action.label}</button>;
457
+ }
458
+
459
+ return <button className="secondary-action" disabled key={action.id} title={action.command_preview} type="button">{action.label}</button>;
460
+ })}
461
+ </div>
462
+ </div>
463
+ </li>
464
+ );
465
+ }
466
+
467
+ function NotificationIcon({ notification }: { notification: GuiNotificationSummary }): ReactElement {
468
+ if (notification.severity === "error") return <FiAlertTriangle />;
469
+ if (notification.severity === "warning") return <FiTool />;
470
+ if (notification.severity === "success") return <FiCheckCircle />;
471
+ return <FiInfo />;
472
+ }
473
+
474
+ function isSurfaceId(value: string | undefined): value is SurfaceId {
475
+ return surfaceIds.includes(value as SurfaceId);
476
+ }
477
+
410
478
  function AdminSurface(): ReactElement {
411
479
  return (
412
480
  <section className="settings-surface">