aidevops 3.29.42 → 3.30.0

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.42-blue.svg)](https://github.com/marcusquinn/aidevops/releases)
61
+ [![Version](https://img.shields.io/badge/Version-3.30.0-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.42
1
+ 3.30.0
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.42
8
+ # Version: 3.30.0
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.42",
3
+ "version": "3.30.0",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -122,9 +122,8 @@ export function createGuiApiApp() {
122
122
 
123
123
  app.post(PULSE_WORKERS_ACTION_ROUTE_MANIFEST.route, (context) => {
124
124
  const action = context.req.param("action") as GuiPulseWorkerActionId;
125
- const actionCommand = pulseWorkerActionCommands[action];
126
125
 
127
- if (actionCommand === undefined) {
126
+ if (!Object.hasOwn(pulseWorkerActionCommands, action)) {
128
127
  return context.json(createEnvelope({
129
128
  operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
130
129
  source: { surface: "pulse_workers", authority: "allowlisted local command runner", path_refs: [] },
@@ -133,6 +132,7 @@ export function createGuiApiApp() {
133
132
  }), 400);
134
133
  }
135
134
 
135
+ const actionCommand = pulseWorkerActionCommands[action];
136
136
  const job = startPulseWorkerActionJob(action, actionCommand.command, actionCommand.target_ref, actionCommand.audit_ref);
137
137
  return context.json(createEnvelope({
138
138
  operation_id: PULSE_WORKERS_ACTION_ROUTE_MANIFEST.operation_id,
@@ -199,6 +199,7 @@ export function createGuiApiApp() {
199
199
 
200
200
  function startAppActionJob(appId: string, action: GuiAppActionId, command: string[]): GuiAppActionJobSummary {
201
201
  const now = new Date().toISOString();
202
+ const redactLine = createOutputLineRedactor();
202
203
  const job: GuiAppActionJobSummary = {
203
204
  id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
204
205
  app_id: appId,
@@ -219,11 +220,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
219
220
  });
220
221
  if (child.stdout !== null) {
221
222
  const stdoutLines = readline.createInterface({ input: child.stdout, terminal: false });
222
- stdoutLines.on("line", (line) => appendJobLine(job, line));
223
+ stdoutLines.on("line", (line) => appendJobLine(job, line, redactLine));
223
224
  }
224
225
  if (child.stderr !== null) {
225
226
  const stderrLines = readline.createInterface({ input: child.stderr, terminal: false });
226
- stderrLines.on("line", (line) => appendJobLine(job, line));
227
+ stderrLines.on("line", (line) => appendJobLine(job, line, redactLine));
227
228
  }
228
229
  child.on("error", (error) => {
229
230
  appendJobLine(job, error.message);
@@ -240,11 +241,11 @@ function startAppActionJob(appId: string, action: GuiAppActionId, command: strin
240
241
  return job;
241
242
  }
242
243
 
243
- function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
244
+ function appendJobLine(job: GuiAppActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
244
245
  if (line.length === 0) {
245
246
  return;
246
247
  }
247
- job.output.push(redactOutputLine(line));
248
+ job.output.push(redactLine(line));
248
249
  if (job.output.length > 400) {
249
250
  job.output.splice(1, job.output.length - 400);
250
251
  }
@@ -252,6 +253,7 @@ function appendJobLine(job: GuiAppActionJobSummary, line: string): void {
252
253
 
253
254
  function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: string[], targetRef: string, auditRef: string): GuiPulseWorkerActionJobSummary {
254
255
  const now = new Date().toISOString();
256
+ const redactLine = createOutputLineRedactor();
255
257
  const job: GuiPulseWorkerActionJobSummary = {
256
258
  id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
257
259
  action,
@@ -265,7 +267,7 @@ function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: stri
265
267
  audit_ref: auditRef,
266
268
  };
267
269
  pulseWorkerActionJobs.set(job.id, job);
268
- startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line), (code) => {
270
+ startBackgroundProcess(command, (line) => appendPulseWorkerJobLine(job, line, redactLine), (code) => {
269
271
  job.status = code === 0 ? "completed" : "failed";
270
272
  job.finished_at = new Date().toISOString();
271
273
  job.exit_code = code;
@@ -274,17 +276,26 @@ function startPulseWorkerActionJob(action: GuiPulseWorkerActionId, command: stri
274
276
  return job;
275
277
  }
276
278
 
277
- function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string): void {
279
+ function appendPulseWorkerJobLine(job: GuiPulseWorkerActionJobSummary, line: string, redactLine: (line: string) => string = redactOutputLine): void {
278
280
  if (line.length === 0) {
279
281
  return;
280
282
  }
281
- job.output.push(redactOutputLine(line));
283
+ job.output.push(redactLine(line));
282
284
  if (job.output.length > 400) {
283
285
  job.output.splice(1, job.output.length - 400);
284
286
  }
285
287
  }
286
288
 
287
289
  function startBackgroundProcess(command: string[], appendLine: (line: string) => void, finish: (code: number) => void): void {
290
+ let finished = false;
291
+ const safeFinish = (code: number): void => {
292
+ if (finished) {
293
+ return;
294
+ }
295
+ finished = true;
296
+ finish(code);
297
+ };
298
+
288
299
  const child = spawn(command[0], command.slice(1), {
289
300
  cwd: process.cwd(),
290
301
  env: { ...process.env, AIDEVOPS_NON_INTERACTIVE: "true", CLICOLOR_FORCE: "1", FORCE_COLOR: "1", TERM: process.env.TERM ?? "xterm-256color" },
@@ -298,9 +309,29 @@ function startBackgroundProcess(command: string[], appendLine: (line: string) =>
298
309
  }
299
310
  child.on("error", (error) => {
300
311
  appendLine(error.message);
301
- finish(127);
312
+ safeFinish(127);
302
313
  });
303
- child.on("close", (code) => finish(code ?? 1));
314
+ child.on("close", (code) => safeFinish(code ?? 1));
315
+ }
316
+
317
+ export function createOutputLineRedactor(): (line: string) => string {
318
+ let inPrivateKeyBlock = false;
319
+
320
+ return (line: string): string => {
321
+ if (/-----BEGIN [A-Z ]+ PRIVATE KEY-----/.test(line)) {
322
+ inPrivateKeyBlock = !/-----END [A-Z ]+ PRIVATE KEY-----/.test(line);
323
+ return "[redacted private key]";
324
+ }
325
+
326
+ if (inPrivateKeyBlock) {
327
+ if (/-----END [A-Z ]+ PRIVATE KEY-----/.test(line)) {
328
+ inPrivateKeyBlock = false;
329
+ }
330
+ return "[redacted private key]";
331
+ }
332
+
333
+ return redactOutputLine(line);
334
+ };
304
335
  }
305
336
 
306
337
  function redactOutputLine(line: string): string {
@@ -341,6 +341,7 @@ final class DraggableTitlebarView: NSView {
341
341
  final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, NSMenuItemValidation, NSWindowDelegate {
342
342
  private var window: NSWindow!
343
343
  private var aboutWindow: NSWindow?
344
+ private var titlebarCameraButton: NSButton?
344
345
  private var titlebarToggleButton: NSButton?
345
346
  private var webView: WKWebView!
346
347
  private let apiPort = ProcessInfo.processInfo.environment["AIDEVOPS_GUI_API_PORT"] ?? "8787"
@@ -408,9 +409,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
408
409
  return
409
410
  }
410
411
 
412
+ if revealFileURL(url) {
413
+ return
414
+ }
415
+
411
416
  _ = openExternalURL(url)
412
417
  }
413
418
 
419
+ private func revealFileURL(_ url: URL) -> Bool {
420
+ guard url.isFileURL else {
421
+ return false
422
+ }
423
+
424
+ NSWorkspace.shared.activateFileViewerSelecting([url])
425
+ return true
426
+ }
427
+
414
428
  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
415
429
  guard let url = navigationAction.request.url else {
416
430
  decisionHandler(.allow)
@@ -552,7 +566,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
552
566
  return webView?.canGoBack ?? false
553
567
  case #selector(goForward(_:)):
554
568
  return webView?.canGoForward ?? false
555
- case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)):
569
+ case #selector(reloadPage(_:)), #selector(stopLoading(_:)), #selector(resetZoom(_:)), #selector(zoomIn(_:)), #selector(zoomOut(_:)), #selector(captureAppScreenshot(_:)), #selector(capturePageScreenshot(_:)):
556
570
  return webView != nil
557
571
  default:
558
572
  return true
@@ -779,11 +793,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
779
793
 
780
794
  overlay.addSubview(toggleButton)
781
795
  titlebarToggleButton = toggleButton
796
+
797
+ let cameraButton = NSButton(frame: .zero)
798
+ cameraButton.action = #selector(showScreenshotMenu(_:))
799
+ cameraButton.bezelStyle = .regularSquare
800
+ cameraButton.contentTintColor = defaultButtonAccent
801
+ cameraButton.image = cameraImage()
802
+ cameraButton.imagePosition = .imageOnly
803
+ cameraButton.isBordered = false
804
+ cameraButton.setButtonType(.momentaryChange)
805
+ cameraButton.target = self
806
+ cameraButton.toolTip = "Capture an aidevops screenshot"
807
+ cameraButton.translatesAutoresizingMaskIntoConstraints = false
808
+
809
+ overlay.addSubview(cameraButton)
810
+ titlebarCameraButton = cameraButton
782
811
  NSLayoutConstraint.activate([
783
812
  toggleButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 92),
784
813
  toggleButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
785
814
  toggleButton.heightAnchor.constraint(equalToConstant: 22),
786
- toggleButton.widthAnchor.constraint(equalToConstant: 22)
815
+ toggleButton.widthAnchor.constraint(equalToConstant: 22),
816
+ cameraButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -92),
817
+ cameraButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: 3.5),
818
+ cameraButton.heightAnchor.constraint(equalToConstant: 22),
819
+ cameraButton.widthAnchor.constraint(equalToConstant: 22)
787
820
  ])
788
821
 
789
822
  return overlay
@@ -791,6 +824,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
791
824
 
792
825
  private func updateTitlebarAccent(hue: CGFloat) {
793
826
  let normalizedHue = min(359, max(0, hue))
827
+ titlebarCameraButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
794
828
  titlebarToggleButton?.contentTintColor = accentColor(hueDegrees: normalizedHue, alpha: 0.9)
795
829
  }
796
830
 
@@ -866,10 +900,191 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate,
866
900
  return image
867
901
  }
868
902
 
903
+ private func cameraImage() -> NSImage {
904
+ let image = NSImage(size: NSSize(width: 20, height: 20), flipped: false) { _ in
905
+ let body = NSBezierPath(roundedRect: NSRect(x: 3, y: 6, width: 14, height: 10), xRadius: 2.7, yRadius: 2.7)
906
+ body.lineWidth = 2
907
+ NSColor.labelColor.setStroke()
908
+ body.stroke()
909
+
910
+ let prism = NSBezierPath(roundedRect: NSRect(x: 6, y: 14, width: 5, height: 2.3), xRadius: 1, yRadius: 1)
911
+ prism.lineWidth = 1.8
912
+ prism.stroke()
913
+
914
+ let lens = NSBezierPath(ovalIn: NSRect(x: 7.1, y: 8.1, width: 5.8, height: 5.8))
915
+ lens.lineWidth = 1.8
916
+ lens.stroke()
917
+
918
+ return true
919
+ }
920
+ image.isTemplate = true
921
+ return image
922
+ }
923
+
869
924
  @objc private func toggleMachineRail(_ sender: Any?) {
870
925
  webView.evaluateJavaScript("window.dispatchEvent(new CustomEvent('aidevops:toggle-machine-rail')); true;", completionHandler: nil)
871
926
  }
872
927
 
928
+ @objc private func showScreenshotMenu(_ sender: Any?) {
929
+ guard let button = sender as? NSButton else {
930
+ return
931
+ }
932
+
933
+ let menu = NSMenu(title: "Screenshots")
934
+ menu.addItem(menuItem("Screenshot App", action: #selector(captureAppScreenshot(_:)), target: self))
935
+ menu.addItem(menuItem("Screenshot Page", action: #selector(capturePageScreenshot(_:)), target: self))
936
+ menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 3), in: button)
937
+ }
938
+
939
+ @objc private func captureAppScreenshot(_ sender: Any?) {
940
+ guard let contentView = window.contentView else {
941
+ return
942
+ }
943
+
944
+ let bounds = contentView.bounds
945
+ guard let representation = contentView.bitmapImageRepForCachingDisplay(in: bounds) else {
946
+ return
947
+ }
948
+
949
+ contentView.cacheDisplay(in: bounds, to: representation)
950
+ let image = NSImage(size: bounds.size)
951
+ image.addRepresentation(representation)
952
+ saveScreenshot(image: image, nameComponent: "window")
953
+ }
954
+
955
+ @objc private func capturePageScreenshot(_ sender: Any?) {
956
+ let script = """
957
+ (() => {
958
+ const el = document.querySelector('.workspace-scroll');
959
+ const title = document.querySelector('.header-title h1')?.textContent || document.title || 'page';
960
+ if (!el) return null;
961
+ const rect = el.getBoundingClientRect();
962
+ return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, scrollHeight: el.scrollHeight, title, originalScrollTop: el.scrollTop };
963
+ })();
964
+ """
965
+
966
+ webView.evaluateJavaScript(script) { [weak self] result, _ in
967
+ guard let self = self, let info = result as? [String: Any] else {
968
+ return
969
+ }
970
+
971
+ self.captureScrollableWorkspace(info: info)
972
+ }
973
+ }
974
+
975
+ private func captureScrollableWorkspace(info: [String: Any]) {
976
+ guard let x = info["x"] as? NSNumber,
977
+ let y = info["y"] as? NSNumber,
978
+ let width = info["width"] as? NSNumber,
979
+ let height = info["height"] as? NSNumber,
980
+ let scrollHeight = info["scrollHeight"] as? NSNumber else {
981
+ return
982
+ }
983
+
984
+ let viewportHeight = max(1, CGFloat(height.doubleValue))
985
+ let contentHeight = max(viewportHeight, CGFloat(scrollHeight.doubleValue))
986
+ let contentWidth = max(1, CGFloat(width.doubleValue))
987
+ let captureRect = CGRect(x: CGFloat(x.doubleValue), y: CGFloat(y.doubleValue), width: contentWidth, height: viewportHeight)
988
+ let pageName = sanitizeFilenameComponent((info["title"] as? String) ?? "page")
989
+ let originalScrollTop = CGFloat((info["originalScrollTop"] as? NSNumber)?.doubleValue ?? 0)
990
+ let composite = NSImage(size: NSSize(width: contentWidth, height: contentHeight))
991
+ captureWorkspaceChunk(rect: captureRect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: 0, pageName: pageName, composite: composite, capturedOffsets: Set<Int>())
992
+ }
993
+
994
+ private func captureWorkspaceChunk(rect: CGRect, contentHeight: CGFloat, viewportHeight: CGFloat, originalScrollTop: CGFloat, targetOffset: CGFloat, pageName: String, composite: NSImage, capturedOffsets: Set<Int>) {
995
+ let scrollScript = """
996
+ (() => {
997
+ const el = document.querySelector('.workspace-scroll');
998
+ if (!el) return null;
999
+ el.scrollTop = \(Int(targetOffset));
1000
+ return el.scrollTop;
1001
+ })();
1002
+ """
1003
+
1004
+ webView.evaluateJavaScript(scrollScript) { [weak self] result, _ in
1005
+ guard let self = self else {
1006
+ return
1007
+ }
1008
+
1009
+ let actualOffset = CGFloat((result as? NSNumber)?.doubleValue ?? Double(targetOffset))
1010
+ let offsetKey = Int(actualOffset.rounded())
1011
+ var nextCapturedOffsets = capturedOffsets
1012
+ if capturedOffsets.contains(offsetKey) {
1013
+ self.restoreWorkspaceScroll(originalScrollTop)
1014
+ self.saveScreenshot(image: composite, nameComponent: pageName)
1015
+ return
1016
+ }
1017
+ nextCapturedOffsets.insert(offsetKey)
1018
+
1019
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
1020
+ let configuration = WKSnapshotConfiguration()
1021
+ configuration.rect = rect
1022
+ self.webView.takeSnapshot(with: configuration) { snapshot, _ in
1023
+ if let snapshot = snapshot {
1024
+ composite.lockFocus()
1025
+ snapshot.draw(in: NSRect(x: 0, y: contentHeight - actualOffset - viewportHeight, width: rect.width, height: viewportHeight), from: .zero, operation: .copy, fraction: 1)
1026
+ composite.unlockFocus()
1027
+ }
1028
+
1029
+ if actualOffset + viewportHeight >= contentHeight - 1 {
1030
+ self.restoreWorkspaceScroll(originalScrollTop)
1031
+ self.saveScreenshot(image: composite, nameComponent: pageName)
1032
+ } else {
1033
+ let nextOffset = min(contentHeight - viewportHeight, actualOffset + viewportHeight)
1034
+ self.captureWorkspaceChunk(rect: rect, contentHeight: contentHeight, viewportHeight: viewportHeight, originalScrollTop: originalScrollTop, targetOffset: nextOffset, pageName: pageName, composite: composite, capturedOffsets: nextCapturedOffsets)
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ private func restoreWorkspaceScroll(_ scrollTop: CGFloat) {
1042
+ webView.evaluateJavaScript("document.querySelector('.workspace-scroll')?.scrollTo({ top: \(Int(scrollTop)), behavior: 'instant' }); true;", completionHandler: nil)
1043
+ }
1044
+
1045
+ private func saveScreenshot(image: NSImage, nameComponent: String) {
1046
+ guard let tiff = image.tiffRepresentation,
1047
+ let bitmap = NSBitmapImageRep(data: tiff),
1048
+ let data = bitmap.representation(using: .png, properties: [:]) else {
1049
+ return
1050
+ }
1051
+
1052
+ let directory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Screenshots", isDirectory: true)
1053
+ do {
1054
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
1055
+ let fileURL = directory.appendingPathComponent("aidevops-app-screenshot-\(sanitizeFilenameComponent(nameComponent))-\(screenshotTimestamp()).png")
1056
+ try data.write(to: fileURL, options: .atomic)
1057
+ NSPasteboard.general.clearContents()
1058
+ NSPasteboard.general.setString(fileURL.path, forType: .string)
1059
+ notifyScreenshotCaptured(fileURL: fileURL)
1060
+ } catch {
1061
+ NSSound.beep()
1062
+ }
1063
+ }
1064
+
1065
+ private func screenshotTimestamp() -> String {
1066
+ let formatter = DateFormatter()
1067
+ formatter.dateFormat = "yyMMdd-HHmmss"
1068
+ return formatter.string(from: Date())
1069
+ }
1070
+
1071
+ private func sanitizeFilenameComponent(_ value: String) -> String {
1072
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
1073
+ let mapped = value.lowercased().unicodeScalars.map { allowed.contains($0) ? String($0) : "-" }.joined()
1074
+ let collapsed = mapped.split(separator: "-").joined(separator: "-")
1075
+ return collapsed.isEmpty ? "page" : collapsed
1076
+ }
1077
+
1078
+ private func notifyScreenshotCaptured(fileURL: URL) {
1079
+ guard let payloadData = try? JSONSerialization.data(withJSONObject: ["path": fileURL.path, "url": fileURL.absoluteString], options: []),
1080
+ let payload = String(data: payloadData, encoding: .utf8) else {
1081
+ return
1082
+ }
1083
+
1084
+ let script = "window.dispatchEvent(new CustomEvent('aidevops:screenshot-captured', { detail: \(payload) })); true;"
1085
+ webView.evaluateJavaScript(script, completionHandler: nil)
1086
+ }
1087
+
873
1088
  private func saveMainWindowFrame() {
874
1089
  guard window != nil else {
875
1090
  return
@@ -4,7 +4,9 @@ import { MachineRail, Sidebar } from "./AppNavigation";
4
4
  import { Workspace } from "./AppWorkspace";
5
5
  import type { ContrastPreference, ConversationMode, FontPreference, FontSizePreference, ShellMode, SurfaceId, ThemePreference } from "./app-model";
6
6
  import { DEFAULT_ACCENT_HUE, DEFAULT_CONTRAST, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isContrastPreference, isFontPreference, isFontSizePreference } from "./app-model";
7
+ import { DesktopStatusBar } from "./DesktopStatusBar";
7
8
  import { type NavigationHistory, nextHistoryIndex, useNavigationHistoryKeyboard } from "./navigation-history";
9
+ import { ScreenshotCaptureNotificationHost } from "./ScreenshotCaptureNotification";
8
10
  import { fetchStatus, mockedStatus } from "./status-client";
9
11
 
10
12
  interface WebKitBridgeWindow extends Window {
@@ -214,6 +216,7 @@ export function App(): ReactElement {
214
216
  return (
215
217
  <main className={machineRailVisible ? "app-shell" : "app-shell machine-rail-collapsed"}>
216
218
  <div className="desktop-titlebar-tagline" aria-hidden="true">Your data protected. Your systems managed. Your creations published.</div>
219
+ <ScreenshotCaptureNotificationHost />
217
220
  {machineRailVisible ? <MachineRail machine={status.data.machine} /> : null}
218
221
  <Sidebar
219
222
  activeSurface={activeSurface}
@@ -423,29 +426,3 @@ function readStoredBoolean(storage: ReadableAppearanceStorage | undefined, key:
423
426
 
424
427
  return fallback;
425
428
  }
426
-
427
- function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
428
- const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
429
- const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
430
- const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
431
- const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
432
- const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
433
- const remoteRepoCount = status.repos.total || status.repos.repos.length;
434
- const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
435
- const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
436
-
437
- return (
438
- <div className="desktop-status-bar" role="status">
439
- <span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
440
- <strong>{statusLabel}</strong>
441
- <span>Read-only local GUI</span>
442
- <span>{versionLabel}</span>
443
- <span>{localRepoCount} local repos</span>
444
- <span>{remoteRepoCount} remote repos</span>
445
- <span>{status.secrets.length} secrets</span>
446
- <span>{vaultLabel}</span>
447
- <span>{oauthAccounts} provider accounts</span>
448
- <span>{needsUpdate} need update</span>
449
- </div>
450
- );
451
- }
@@ -0,0 +1,28 @@
1
+ import type { GuiStatusData } from "@aidevops/gui-shared";
2
+ import type { ReactElement } from "react";
3
+
4
+ export function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
5
+ const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
6
+ const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
7
+ const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
8
+ const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
9
+ const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
10
+ const remoteRepoCount = status.repos.total || status.repos.repos.length;
11
+ const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
12
+ const vaultLabel = status.vault?.unlocked ? "Vault unlocked" : `Vault ${status.vault?.status ?? "unknown"}`;
13
+
14
+ return (
15
+ <div className="desktop-status-bar" role="status">
16
+ <span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
17
+ <strong>{statusLabel}</strong>
18
+ <span>Read-only local GUI</span>
19
+ <span>{versionLabel}</span>
20
+ <span>{localRepoCount} local repos</span>
21
+ <span>{remoteRepoCount} remote repos</span>
22
+ <span>{status.secrets.length} secrets</span>
23
+ <span>{vaultLabel}</span>
24
+ <span>{oauthAccounts} provider accounts</span>
25
+ <span>{needsUpdate} need update</span>
26
+ </div>
27
+ );
28
+ }
@@ -16,7 +16,7 @@ function DevOpsCard({ validation }: { validation: GuiTamboValidationResult }) {
16
16
  const entries = Object.entries(props).filter(([key]) => key !== "title" && key !== "disabled");
17
17
 
18
18
  return (
19
- <section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component} ${text.devOpsCard}`}>
19
+ <section className="genui-card" data-genui-component={validation.component ?? "unknown"} aria-label={`${validation.component ?? "unknown"} ${text.devOpsCard}`}>
20
20
  <header>
21
21
  <p className="eyebrow">{text.tamboGenUiReadOnly}</p>
22
22
  <h3>{stringProp(props.title) ?? validation.component}</h3>
@@ -266,7 +266,10 @@ function PulseDrilldownPanel({ event }: { event: PulseEvent | undefined }): Reac
266
266
  }
267
267
 
268
268
  function relatedFindings(event: PulseEvent): string[] {
269
- return event.drilldown_sections?.filter((section) => section.label.toLowerCase().includes("systemic") || section.label.toLowerCase().includes("verification")).map((section) => `${section.label}: ${section.body}`) ?? [];
269
+ return event.drilldown_sections?.filter((section) => {
270
+ const label = section.label?.toLowerCase();
271
+ return label?.includes("systemic") || label?.includes("verification");
272
+ }).map((section) => `${section.label}: ${section.body}`) ?? [];
270
273
  }
271
274
 
272
275
  function buildFilterGroups(pulse: GuiStatusData["pulse_workers"]): PulseFilterGroup[] {
@@ -0,0 +1,51 @@
1
+ import { type ReactElement, useEffect, useState } from "react";
2
+
3
+ export interface ScreenshotCapturedDetail {
4
+ path: string;
5
+ url: string;
6
+ }
7
+
8
+ interface WebKitExternalLinkWindow extends Window {
9
+ webkit?: {
10
+ messageHandlers?: {
11
+ externalLink?: {
12
+ postMessage: (href: string) => void;
13
+ };
14
+ };
15
+ };
16
+ }
17
+
18
+ export function ScreenshotCaptureNotification({ notification, onDismiss }: { notification: ScreenshotCapturedDetail; onDismiss: () => void }): ReactElement {
19
+ const revealScreenshot = () => {
20
+ const webkit = (window as WebKitExternalLinkWindow).webkit;
21
+ webkit?.messageHandlers?.externalLink?.postMessage(notification.url);
22
+ };
23
+
24
+ return (
25
+ <div className="screenshot-capture-notification" role="status">
26
+ <div>
27
+ <strong>Screenshot captured</strong>
28
+ <span>Saved to <button onClick={revealScreenshot} type="button">{notification.path}</button>; path copied to clipboard.</span>
29
+ </div>
30
+ <button aria-label="Dismiss screenshot notification" onClick={onDismiss} type="button">×</button>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export function ScreenshotCaptureNotificationHost(): ReactElement | null {
36
+ const [screenshotNotification, setScreenshotNotification] = useState<ScreenshotCapturedDetail | undefined>();
37
+
38
+ useEffect(() => {
39
+ const showScreenshotNotification = (event: Event) => {
40
+ const detail = (event as CustomEvent<Partial<ScreenshotCapturedDetail>>).detail;
41
+ if (detail !== undefined && typeof detail.path === "string" && typeof detail.url === "string") {
42
+ setScreenshotNotification({ path: detail.path, url: detail.url });
43
+ }
44
+ };
45
+
46
+ window.addEventListener("aidevops:screenshot-captured", showScreenshotNotification);
47
+ return () => window.removeEventListener("aidevops:screenshot-captured", showScreenshotNotification);
48
+ }, []);
49
+
50
+ return screenshotNotification ? <ScreenshotCaptureNotification notification={screenshotNotification} onDismiss={() => setScreenshotNotification(undefined)} /> : null;
51
+ }
@@ -218,7 +218,7 @@ body {
218
218
  }
219
219
 
220
220
  :root[data-theme="dark"] body {
221
- background:
221
+ background-image:
222
222
  radial-gradient(ellipse 78% 64% at top left,
223
223
  hsl(var(--accent-hue) 96% 22% / 62%) 0%,
224
224
  hsl(var(--accent-hue) 92% 18% / 34%) 34%,
@@ -298,6 +298,68 @@ code {
298
298
  z-index: 20;
299
299
  }
300
300
 
301
+ .screenshot-capture-notification {
302
+ align-items: center;
303
+ background: color-mix(in srgb, var(--surface-elevated) 94%, transparent);
304
+ border: 1px solid var(--border-accent);
305
+ border-radius: 999px;
306
+ box-shadow: var(--glass-panel-shadow);
307
+ color: var(--text-secondary);
308
+ display: flex;
309
+ gap: 12px;
310
+ left: 50%;
311
+ max-width: min(720px, calc(100vw - 48px));
312
+ padding: 8px 10px 8px 14px;
313
+ position: fixed;
314
+ top: calc(var(--desktop-titlebar-height) + 10px);
315
+ transform: translateX(-50%);
316
+ z-index: 40;
317
+ }
318
+
319
+ .screenshot-capture-notification > div {
320
+ display: grid;
321
+ gap: 1px;
322
+ min-width: 0;
323
+ }
324
+
325
+ .screenshot-capture-notification strong {
326
+ color: var(--text-primary);
327
+ font-size: 0.78rem;
328
+ line-height: 1.2;
329
+ }
330
+
331
+ .screenshot-capture-notification span {
332
+ font-size: 0.74rem;
333
+ line-height: 1.25;
334
+ overflow: hidden;
335
+ text-overflow: ellipsis;
336
+ white-space: nowrap;
337
+ }
338
+
339
+ .screenshot-capture-notification button {
340
+ background: transparent;
341
+ border: 0;
342
+ color: var(--accent);
343
+ font: inherit;
344
+ padding: 0;
345
+ text-decoration: underline;
346
+ }
347
+
348
+ .screenshot-capture-notification > button {
349
+ align-items: center;
350
+ background: var(--surface-muted);
351
+ border: 1px solid transparent;
352
+ border-radius: 999px;
353
+ color: var(--text-secondary);
354
+ display: inline-flex;
355
+ flex: 0 0 auto;
356
+ font-size: 1rem;
357
+ height: 24px;
358
+ justify-content: center;
359
+ line-height: 1;
360
+ width: 24px;
361
+ }
362
+
301
363
  .machine-rail,
302
364
  .app-sidebar,
303
365
  .app-inset {
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.29.42
15
+ # Version: 3.30.0
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)