cursor-guard 4.5.8 → 4.6.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/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.5.8`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.8` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.6.0`
7
+ > **文档状态**:`V2` ~ `V4.6.0` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -464,7 +464,8 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
464
464
  | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
465
465
  | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
466
  | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
467
- | V4.5.8 | **Dashboard 版本更新检测**:见下方详细说明 | ✅ |
467
+ | V4.5.8 | **Dashboard 版本更新检测 + 一键重启**:见下方详细说明 | ✅ |
468
+ | V4.6.0 | **告警 UX 全面升级**:见下方详细说明 | ✅ |
468
469
 
469
470
  #### V4.4.1 详细内容
470
471
 
@@ -674,14 +675,28 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
674
675
 
675
676
  #### V4.5.8 详细内容
676
677
 
677
- **Dashboard 版本更新检测**:
678
+ **Dashboard 版本更新检测 + 一键重启**:
678
679
 
679
680
  | 组件 | 说明 |
680
681
  |------|------|
681
- | `/api/version` 端点 | 新增无需 project id 的公共 API。返回 `serverVersion`(进程启动时 require cache 锁定)和 `installedVersion`(实时从磁盘读取 `package.json`),以及 `updateAvailable` 布尔值 |
682
- | 前端版本检查 | `init()` 完成后异步调用 `checkServerVersion()`,非阻塞,失败静默 |
683
- | 升级横幅 | `updateAvailable === true` 时,在 topbar 下方滑入黄色横幅,显示版本差异和重启提示。带"如何重启"按钮(展开命令示例)和关闭按钮 |
684
- | 双语支持 | 新增 `upgrade.banner` / `upgrade.dismiss` / `upgrade.restart` / `upgrade.hint` 四组 i18n key |
682
+ | `/api/version` 端点 | 返回 `serverVersion`(启动时 require cache)和 `installedVersion`(实时从磁盘读取),以及 `updateAvailable` 布尔值 |
683
+ | `/api/restart` 端点 (POST) | 触发服务热重启:关闭旧 HTTP server → `clearGuardCache()` 清除所有 cursor-guard 模块的 require cache → 更新 `SERVER_VERSION` → 在同端口重启新 server |
684
+ | 懒加载 core deps | `handleApi` `getDashboard`/`runDiagnostics`/`listBackups`/`getBackupFiles` 改为通过 `coreDeps()` 懒加载,重启后自动加载新代码 |
685
+ | 升级横幅 | `updateAvailable === true` 时显示黄色横幅。包含"一键重启"按钮(发送 POST 轮询等待 自动 reload)、"手动重启"按钮(展开命令示例)、关闭按钮 |
686
+ | 重启流程 | 前端发 POST `/api/restart` → 显示"正在重启..." → 每 500ms 轮询 `/api/version`(最多 10s)→ 服务就绪后 `location.reload()` 自动刷新页面 |
687
+ | 双语支持 | 新增 `upgrade.*` 共 8 组 i18n key(banner/dismiss/restartNow/restart/hint/restarting/waiting/failed) |
688
+
689
+ #### V4.6.0 详细内容
690
+
691
+ **告警 UX 全面升级**:
692
+
693
+ | 修复项 | 说明 |
694
+ |------|------|
695
+ | 倒计时秒级实时更新 | `state.alertExpiresAt` 在 `renderAlertCard` 时记录过期时间;已有的 1 秒定时器 `updateRefreshDisplay` 中实时更新 `.alert-countdown` 元素,无需额外 `setInterval` |
696
+ | 告警文件详情复制/恢复操作 | 告警详情弹窗现在传入 `commitHash`(从 `alerts.latest.commitHash` 或 `'HEAD'` 回退),使每行文件的"复制恢复命令"按钮正常显示和工作 |
697
+ | 备份过时阈值修正 | 阈值从 `interval * 5 / 60` 分钟改为 `max(interval * 10, 300)` 秒(至少 5 分钟);仅在 watcher 正在运行时才检查过时状态;文案从"stale"改为中性的"Last git backup: X ago" |
698
+ | 告警历史常驻入口 | `buildAlertHistoryHtml()` 提取为独立函数,在活跃告警和无告警两种状态下都渲染"历史 N 条"折叠按钮,告警激活时也能查看历史 |
699
+ | 告警历史 localStorage 持久化 | 新增 `loadAlertHistory()` / `saveAlertHistory()`,使用 `localStorage` key `cursorGuard_alertHistory` 持久化最近 20 条告警历史,刷新页面不丢失 |
685
700
 
686
701
  #### V4.5.x 新增配置参考
687
702
 
@@ -707,6 +722,15 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
707
722
  }
708
723
  ```
709
724
 
725
+ ### V4.7 规划:IDE 集成(VSCode/Cursor Extension)
726
+
727
+ | 项目 | 说明 |
728
+ |------|------|
729
+ | 定位 | 将 Dashboard 从浏览器迁移到 IDE 内置 WebView,减少上下文切换 |
730
+ | 实现方式 | VSCode Extension + WebView Panel,复用现有 `dashboard/public/` 前端代码 |
731
+ | 核心能力 | IDE 侧边栏入口、状态栏告警指示器、WebView 内嵌 Dashboard、一键快照按钮 |
732
+ | 状态 | 🔮 规划中 |
733
+
710
734
  ### V4 不做的事
711
735
 
712
736
  - 不做自动恢复(恢复永远需要人确认,这是产品底线)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.8",
3
+ "version": "4.6.0",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -142,8 +142,12 @@ const I18N = {
142
142
 
143
143
  'upgrade.banner': 'New version {installed} available (current server: {server}). Please restart the Dashboard service to load the latest features.',
144
144
  'upgrade.dismiss': 'Dismiss',
145
- 'upgrade.restart': 'How to restart',
145
+ 'upgrade.restartNow': 'Restart Now',
146
+ 'upgrade.restart': 'Manual restart',
146
147
  'upgrade.hint': 'Stop the current process (Ctrl+C), then run: cursor-guard-backup --path <dir> --dashboard',
148
+ 'upgrade.restarting': 'Restarting...',
149
+ 'upgrade.waiting': 'Waiting for server...',
150
+ 'upgrade.failed': 'Restart failed, try manually',
147
151
 
148
152
  'strategy.git': 'Git',
149
153
  'strategy.shadow': 'Shadow',
@@ -362,8 +366,12 @@ const I18N = {
362
366
 
363
367
  'upgrade.banner': '检测到新版本 {installed}(当前服务: {server}),请重启 Dashboard 服务以加载最新功能',
364
368
  'upgrade.dismiss': '关闭',
365
- 'upgrade.restart': '如何重启',
369
+ 'upgrade.restartNow': '一键重启',
370
+ 'upgrade.restart': '手动重启',
366
371
  'upgrade.hint': '停止当前进程 (Ctrl+C),然后运行: cursor-guard-backup --path <目录> --dashboard',
372
+ 'upgrade.restarting': '正在重启...',
373
+ 'upgrade.waiting': '等待服务就绪...',
374
+ 'upgrade.failed': '重启失败,请手动重启',
367
375
 
368
376
  'strategy.git': 'Git',
369
377
  'strategy.shadow': '影子',
@@ -462,10 +470,23 @@ const state = {
462
470
  tickTimer: null,
463
471
  lastRefreshAt: null,
464
472
  drawerOpen: null,
465
- alertHistory: [],
473
+ alertHistory: loadAlertHistory(),
474
+ alertExpiresAt: null,
466
475
  };
467
476
 
468
477
  const REFRESH_MS = 15000;
478
+ const ALERT_HISTORY_KEY = 'cursorGuard_alertHistory';
479
+
480
+ function loadAlertHistory() {
481
+ try {
482
+ const raw = localStorage.getItem(ALERT_HISTORY_KEY);
483
+ return raw ? JSON.parse(raw) : [];
484
+ } catch { return []; }
485
+ }
486
+
487
+ function saveAlertHistory() {
488
+ try { localStorage.setItem(ALERT_HISTORY_KEY, JSON.stringify(state.alertHistory)); } catch { /* quota */ }
489
+ }
469
490
 
470
491
  /* ── DOM helpers ──────────────────────────────────────────── */
471
492
 
@@ -729,9 +750,17 @@ async function manualRefresh() {
729
750
 
730
751
  function updateRefreshDisplay() {
731
752
  const el = $('#last-refresh');
732
- if (!el || !state.lastRefreshAt) return;
733
- const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
734
- el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
753
+ if (el && state.lastRefreshAt) {
754
+ const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
755
+ el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
756
+ }
757
+
758
+ const cdEl = document.querySelector('.alert-countdown');
759
+ if (cdEl && state.alertExpiresAt) {
760
+ const remain = Math.max(0, state.alertExpiresAt - Date.now());
761
+ const s = Math.ceil(remain / 1000);
762
+ cdEl.textContent = s > 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
763
+ }
735
764
  }
736
765
 
737
766
  /* ── Rendering: Top bar ───────────────────────────────────── */
@@ -881,33 +910,35 @@ function alertFileBreakdown(files) {
881
910
  return t('alert.breakdown', { added, modified, deleted });
882
911
  }
883
912
 
913
+ function buildAlertHistoryHtml() {
914
+ if (state.alertHistory.length === 0) return '';
915
+ const count = state.alertHistory.length;
916
+ const rows = state.alertHistory.slice(-5).reverse().map(h => {
917
+ const breakdown = alertFileBreakdown(h.files);
918
+ return `<div class="alert-history-row text-sm text-muted">
919
+ <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
920
+ <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
921
+ ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
922
+ <span class="badge badge-expired">${t('alert.expired')}</span>
923
+ </div>`;
924
+ }).join('');
925
+ return `
926
+ <div class="alert-history-toggle-wrap">
927
+ <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
928
+ </div>
929
+ <div class="alert-history alert-history-collapsed">
930
+ <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
931
+ </div>`;
932
+ }
933
+
884
934
  function renderAlertCard(alerts) {
885
935
  const el = $('#card-alert');
886
936
  if (!alerts?.active) {
887
- let historyHtml = '';
888
- if (state.alertHistory.length > 0) {
889
- const count = state.alertHistory.length;
890
- const rows = state.alertHistory.slice(-5).reverse().map(h => {
891
- const breakdown = alertFileBreakdown(h.files);
892
- return `<div class="alert-history-row text-sm text-muted">
893
- <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
894
- <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
895
- ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
896
- <span class="badge badge-expired">${t('alert.expired')}</span>
897
- </div>`;
898
- }).join('');
899
- historyHtml = `
900
- <div class="alert-history-toggle-wrap">
901
- <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
902
- </div>
903
- <div class="alert-history alert-history-collapsed">
904
- <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
905
- </div>`;
906
- }
937
+ state.alertExpiresAt = null;
907
938
  el.innerHTML = `
908
939
  <div class="card-label">${t('alert.title')}</div>
909
940
  <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
910
- ${historyHtml}
941
+ ${buildAlertHistoryHtml()}
911
942
  `;
912
943
  return;
913
944
  }
@@ -917,10 +948,12 @@ function renderAlertCard(alerts) {
917
948
  if (a.timestamp && !state.alertHistory.some(h => h.timestamp === a.timestamp)) {
918
949
  state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt, files: a.files });
919
950
  if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
951
+ saveAlertHistory();
920
952
  }
921
953
 
922
954
  const triggeredAt = a.timestamp ? formatTime(a.timestamp) : '-';
923
955
  const expiresAt = a.expiresAt ? new Date(a.expiresAt) : null;
956
+ state.alertExpiresAt = expiresAt ? expiresAt.getTime() : null;
924
957
  const remainMs = expiresAt ? expiresAt.getTime() - Date.now() : 0;
925
958
  const remainSec = Math.max(0, Math.ceil(remainMs / 1000));
926
959
  const remainMin = Math.floor(remainSec / 60);
@@ -948,6 +981,7 @@ function renderAlertCard(alerts) {
948
981
  <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
949
982
  </div>
950
983
  ${filesHtml}
984
+ ${buildAlertHistoryHtml()}
951
985
  `;
952
986
  }
953
987
 
@@ -1554,7 +1588,8 @@ function setupEvents() {
1554
1588
  const files = alerts?.latest?.files || [];
1555
1589
  if (files.length > 0) {
1556
1590
  const proj = state.pageData?.dashboard?.watcher?.path || '';
1557
- openFileModal(t('modal.alertFiles'), files, proj, '');
1591
+ const hash = alerts?.latest?.commitHash || 'HEAD';
1592
+ openFileModal(t('modal.alertFiles'), files, proj, hash);
1558
1593
  }
1559
1594
  return;
1560
1595
  }
@@ -1606,7 +1641,8 @@ function showUpgradeBanner(data) {
1606
1641
  banner.className = 'upgrade-banner';
1607
1642
  banner.innerHTML = `
1608
1643
  <span class="upgrade-banner-text">${t('upgrade.banner', { installed: esc(data.installedVersion), server: esc(data.serverVersion) })}</span>
1609
- <button class="upgrade-banner-hint-btn" title="${t('upgrade.hint')}">${t('upgrade.restart')}</button>
1644
+ <button class="upgrade-banner-restart-btn">${t('upgrade.restartNow')}</button>
1645
+ <button class="upgrade-banner-hint-btn">${t('upgrade.restart')}</button>
1610
1646
  <button class="upgrade-banner-close" aria-label="${t('upgrade.dismiss')}">&times;</button>
1611
1647
  `;
1612
1648
  const topbar = $('#topbar');
@@ -1620,6 +1656,42 @@ function showUpgradeBanner(data) {
1620
1656
  el.innerHTML = `<code>${t('upgrade.hint')}</code>`;
1621
1657
  banner.appendChild(el);
1622
1658
  });
1659
+ banner.querySelector('.upgrade-banner-restart-btn').addEventListener('click', () => {
1660
+ restartServer(banner);
1661
+ });
1662
+ }
1663
+
1664
+ async function restartServer(banner) {
1665
+ const btn = banner.querySelector('.upgrade-banner-restart-btn');
1666
+ btn.disabled = true;
1667
+ btn.textContent = t('upgrade.restarting');
1668
+ banner.querySelector('.upgrade-banner-hint-btn').style.display = 'none';
1669
+ banner.querySelector('.upgrade-banner-close').style.display = 'none';
1670
+
1671
+ try {
1672
+ const sep = '/api/restart'.includes('?') ? '&' : '?';
1673
+ const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
1674
+ await fetch('/api/restart' + tokenParam, { method: 'POST' });
1675
+ } catch { /* server may close connection */ }
1676
+
1677
+ btn.textContent = t('upgrade.waiting');
1678
+ let ready = false;
1679
+ for (let i = 0; i < 20; i++) {
1680
+ await new Promise(r => setTimeout(r, 500));
1681
+ try {
1682
+ const r = await fetch('/api/version' + (window.__GUARD_TOKEN__ ? '?token=' + window.__GUARD_TOKEN__ : ''));
1683
+ if (r.ok) { ready = true; break; }
1684
+ } catch { /* still restarting */ }
1685
+ }
1686
+
1687
+ if (ready) {
1688
+ location.reload();
1689
+ } else {
1690
+ btn.textContent = t('upgrade.failed');
1691
+ btn.disabled = false;
1692
+ banner.querySelector('.upgrade-banner-hint-btn').style.display = '';
1693
+ banner.querySelector('.upgrade-banner-close').style.display = '';
1694
+ }
1623
1695
  }
1624
1696
 
1625
1697
  /* ── Init ─────────────────────────────────────────────────── */
@@ -143,6 +143,25 @@ body {
143
143
  .upgrade-banner-text {
144
144
  flex: 1;
145
145
  }
146
+ .upgrade-banner-restart-btn {
147
+ padding: 4px 14px;
148
+ font-size: 12px;
149
+ font-weight: 600;
150
+ border: 1px solid var(--yellow);
151
+ border-radius: var(--radius-sm);
152
+ background: var(--yellow);
153
+ color: var(--bg);
154
+ cursor: pointer;
155
+ white-space: nowrap;
156
+ transition: all 0.15s;
157
+ }
158
+ .upgrade-banner-restart-btn:hover {
159
+ filter: brightness(1.1);
160
+ }
161
+ .upgrade-banner-restart-btn:disabled {
162
+ opacity: 0.7;
163
+ cursor: wait;
164
+ }
146
165
  .upgrade-banner-hint-btn {
147
166
  padding: 3px 10px;
148
167
  font-size: 11px;
@@ -6,13 +6,26 @@ const crypto = require('crypto');
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
- const { getDashboard } = require('../lib/core/dashboard');
10
- const { runDiagnostics } = require('../lib/core/doctor');
11
- const { listBackups, getBackupFiles } = require('../lib/core/backups');
9
+ const GUARD_ROOT = path.resolve(__dirname, '..');
10
+
11
+ function coreDeps() {
12
+ return {
13
+ getDashboard: require('../lib/core/dashboard').getDashboard,
14
+ runDiagnostics: require('../lib/core/doctor').runDiagnostics,
15
+ listBackups: require('../lib/core/backups').listBackups,
16
+ getBackupFiles: require('../lib/core/backups').getBackupFiles,
17
+ };
18
+ }
19
+
20
+ function clearGuardCache() {
21
+ Object.keys(require.cache).forEach(key => {
22
+ if (key.startsWith(GUARD_ROOT)) delete require.cache[key];
23
+ });
24
+ }
12
25
 
13
26
  const PUBLIC_DIR = path.join(__dirname, 'public');
14
27
  const PKG_PATH = path.resolve(__dirname, '..', '..', 'package.json');
15
- const SERVER_VERSION = require('../../package.json').version;
28
+ let SERVER_VERSION = require('../../package.json').version;
16
29
  const DEFAULT_PORT = 3120;
17
30
  const MAX_PORT_RETRIES = 10;
18
31
  const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
@@ -126,7 +139,7 @@ function serveStatic(reqUrl, res, serverToken) {
126
139
 
127
140
  /* ── API routes ─────────────────────────────────────────────── */
128
141
 
129
- function handleApi(pathname, query, registry, res) {
142
+ function handleApi(pathname, query, registry, res, req) {
130
143
  if (pathname === '/api/version') {
131
144
  let installedVersion = SERVER_VERSION;
132
145
  try {
@@ -140,6 +153,17 @@ function handleApi(pathname, query, registry, res) {
140
153
  });
141
154
  }
142
155
 
156
+ if (pathname === '/api/restart') {
157
+ if (req.method !== 'POST') {
158
+ res.writeHead(405);
159
+ return res.end('Method Not Allowed');
160
+ }
161
+ if (!_instance) return json(res, { error: 'No running instance' }, 500);
162
+ json(res, { restarting: true });
163
+ setTimeout(() => restartDashboard(), 300);
164
+ return;
165
+ }
166
+
143
167
  if (pathname === '/api/projects') {
144
168
  const list = [...registry.values()].map(({ id, name, pathLabel }) => ({ id, name, pathLabel }));
145
169
  return json(res, list);
@@ -154,6 +178,8 @@ function handleApi(pathname, query, registry, res) {
154
178
  return json(res, { error: `Project directory not accessible: ${project.pathLabel}` }, 500);
155
179
  }
156
180
 
181
+ const { getDashboard, runDiagnostics, listBackups, getBackupFiles } = coreDeps();
182
+
157
183
  if (pathname === '/api/page-data') {
158
184
  const scope = query.get('scope');
159
185
  const result = { timestamp: new Date().toISOString() };
@@ -258,7 +284,7 @@ function startDashboardServer(paths, opts = {}) {
258
284
  return res.end('Forbidden: invalid host');
259
285
  }
260
286
 
261
- if (req.method !== 'GET') {
287
+ if (req.method !== 'GET' && req.method !== 'POST') {
262
288
  res.writeHead(405);
263
289
  return res.end('Method Not Allowed');
264
290
  }
@@ -272,8 +298,9 @@ function startDashboardServer(paths, opts = {}) {
272
298
  res.writeHead(403);
273
299
  return res.end('Forbidden: invalid token');
274
300
  }
275
- handleApi(parsed.pathname, parsed.searchParams, registry, res);
301
+ handleApi(parsed.pathname, parsed.searchParams, registry, res, req);
276
302
  } else {
303
+ if (req.method !== 'GET') { res.writeHead(405); return res.end('Method Not Allowed'); }
277
304
  serveStatic(req.url, res, token);
278
305
  }
279
306
  });
@@ -309,6 +336,26 @@ function startDashboardServer(paths, opts = {}) {
309
336
  });
310
337
  }
311
338
 
339
+ /* ── Hot Restart ───────────────────────────────────────────── */
340
+
341
+ async function restartDashboard() {
342
+ if (!_instance) return;
343
+ const paths = [..._instance.registry.values()].map(p => p._path);
344
+ const port = _instance.port;
345
+
346
+ _instance.server.close();
347
+ _instance = null;
348
+
349
+ clearGuardCache();
350
+ try {
351
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
352
+ SERVER_VERSION = pkg.version;
353
+ } catch { /* keep old version */ }
354
+
355
+ console.log(` [dashboard] Restarting on port ${port}...`);
356
+ await startDashboardServer(paths, { port, silent: false });
357
+ }
358
+
312
359
  /* ── CLI entry ─────────────────────────────────────────────── */
313
360
 
314
361
  if (require.main === module) {
@@ -168,12 +168,12 @@ function getDashboard(projectDir) {
168
168
  issues.push(`Disk space low (${status.disk.freeGB} GB free)`);
169
169
  }
170
170
 
171
- if (status.lastBackup.git) {
171
+ if (status.lastBackup.git && status.watcher.running) {
172
172
  const lastTs = new Date(status.lastBackup.git.timestamp).getTime();
173
- const staleMinutes = (Date.now() - lastTs) / 60000;
174
- const staleThreshold = Math.min(cfg.auto_backup_interval_seconds * 5 / 60, 30);
175
- if (staleMinutes > staleThreshold) {
176
- issues.push(`Last git backup is stale (${relativeTime(status.lastBackup.git.timestamp)})`);
173
+ const staleSec = (Date.now() - lastTs) / 1000;
174
+ const staleThreshold = Math.max(cfg.auto_backup_interval_seconds * 10, 300);
175
+ if (staleSec > staleThreshold) {
176
+ issues.push(`Last git backup: ${relativeTime(status.lastBackup.git.timestamp)}`);
177
177
  }
178
178
  }
179
179