argusqa-os 9.3.0 β†’ 9.3.1

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
@@ -79,7 +79,7 @@ The `landing/` directory contains the product landing page (React + Vite + Tailw
79
79
 
80
80
  | πŸ”΄ Critical / 🟑 Warning / πŸ”΅ Info | βš™οΈ | πŸ§ͺ | πŸ“‹ |
81
81
  | :---: | :---: | :---: | :---: |
82
- | **114 distinct issue types detected** | **24 analysis engines** | **348 test assertions** | **82 test blocks** |
82
+ | **114 distinct issue types detected** | **24 analysis engines** | **360 test assertions** | **83 test blocks** |
83
83
 
84
84
  </div>
85
85
 
@@ -341,7 +341,7 @@ Argus watches your running application and automatically surfaces issues that te
341
341
  | **GitHub PR Integration** | Posts a structured Markdown findings table as a PR comment (updates in-place β€” one comment per PR, no spam); sets an `argus-qa` commit status check (`failure` when new criticals exist, `success` otherwise) β€” blocks merge via branch protection when regressions are introduced. Requires `GITHUB_TOKEN` + `GITHUB_REPOSITORY` env vars |
342
342
  | **Auto Route Discovery** | Augments manual `routes[]` with paths from three sources: fetches `/sitemap.xml` (follows one sitemap-index level, 10s timeout), scans Next.js `pages/` (Next 12) and `app/` (Next 13+) directories stripping route groups `(auth)`, and greps JS/TS source for React Router `<Route path>` declarations. Dynamic `[param]` segments are skipped β€” no concrete URL to crawl. Manual route config (`critical`, `waitFor`) always takes precedence. |
343
343
  | **`argus init` Setup Wizard** | `npm run init` (or `npx argus init`) guides first-time setup: collects target URLs, detects the app framework (Next.js / React Router / unknown) from the source directory's `package.json`, runs C3 route discovery against the dev URL, prompts for optional Slack tokens and GitHub credentials, then writes a populated `.env` and a pre-filled `src/config/targets.js` β€” zero manual config editing required. |
344
- | **Watch Mode** | `npm run watch` attaches to whatever Chrome tab is open and polls `list_console_messages` + `list_network_requests` every 1 s (configurable via `ARGUS_WATCH_INTERVAL_MS`). Reports new console errors, network failures (4xx/5xx), CORS blocks, and auth failures in real time β€” without navigating. On `Ctrl+C`, generates a final `reports/report.html`. No route config needed. |
344
+ | **Watch Mode** | `npm run watch` attaches to whatever Chrome tab is open and polls `list_console_messages` + `list_network_requests` every 1 s (configurable via `ARGUS_WATCH_INTERVAL_MS`). Reports new console errors, network failures (4xx/5xx), CORS blocks, and auth failures in real time β€” without navigating. Starts a live web dashboard at `http://localhost:3002` (configurable via `ARGUS_WATCH_UI_PORT`). On `Ctrl+C`, generates a final `reports/report.html`. No route config needed. |
345
345
  | **Full Lighthouse Suite** | All 4 Lighthouse categories (performance, SEO, best-practices, accessibility) with per-audit items |
346
346
  | **Performance Budgets** | Enforces LCP < 2500ms, CLS < 0.1, FID < 100ms, TTFB < 800ms per route |
347
347
  | **Slack Notifications** | Rich Block Kit reports with inline screenshots routed to `#bugs-critical`, `#bugs-warnings`, `#bugs-digest` |
@@ -943,11 +943,11 @@ argus/
943
943
  β”‚ └── README.md # Setup guide, Supabase SQL schema, env vars, deployment
944
944
  β”œβ”€β”€ scripts/
945
945
  β”‚ └── dispatch-report.js # Standalone Slack re-dispatch script (re-posts last report.json to Slack)
946
- β”œβ”€β”€ test-harness/ # Fixture server + test runner (82 blocks, 348 hard assertions, 54 fixture pages)
946
+ β”œβ”€β”€ test-harness/ # Fixture server + test runner (83 blocks, 360 hard assertions, 54 fixture pages)
947
947
  β”‚ β”œβ”€β”€ README.md
948
948
  β”‚ β”œβ”€β”€ server.js # Express fixture server (ports 3100 dev / 3101 staging)
949
949
  β”‚ β”œβ”€β”€ harness-config.js # Route definitions + expected findings
950
- β”‚ β”œβ”€β”€ validate.js # Test runner β€” 82 numbered blocks ([80] MCP server, [81] createFinding, [82] withRetry)
950
+ β”‚ β”œβ”€β”€ validate.js # Test runner β€” 83 numbered blocks ([80] MCP server, [81] createFinding, [82] withRetry, [83] watch dashboard)
951
951
  β”‚ β”œβ”€β”€ pages/ # 54 fixture pages (one per detection category)
952
952
  β”‚ β”œβ”€β”€ nextjs-fixture/ # Next.js app structure for C3 discovery tests (10 files)
953
953
  β”‚ β”œβ”€β”€ source-fixture/ # Minimal app.js for C1 codebase-analyzer tests (env var audit)
@@ -988,7 +988,7 @@ argus/
988
988
 
989
989
  ## Known MCP Tool Limitations
990
990
 
991
- The Chrome DevTools MCP behavioral constraints below cause **3 permanent test failures** in the harness (`345/348` pass). These are MCP-layer restrictions β€” they cannot be fixed in Argus code.
991
+ The Chrome DevTools MCP behavioral constraints below cause **3 permanent test failures** in the harness (`357/360` pass). These are MCP-layer restrictions β€” they cannot be fixed in Argus code.
992
992
 
993
993
  > **`type_text` clarification**: `type_text` does fire DOM `input` events when the element is properly focused first with `mcp.click({ uid })`. Always use uid-based focus β€” passing `{ selector }` to `mcp.click` silently does nothing.
994
994
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.3.0",
3
+ "version": "9.3.1",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
5
  "description": "Argus β€” AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
6
6
  "keywords": [
package/src/mcp-server.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argus MCP Server (v9.2.9)
3
+ * Argus MCP Server (v9.3.1)
4
4
  *
5
5
  * Exposes Argus as an MCP server so Claude (or any MCP client) can call
6
- * argus_audit, argus_audit_full, argus_compare, and argus_last_report
7
- * directly from a conversation without using the CLI.
6
+ * argus_audit, argus_audit_full, argus_compare, argus_last_report, and
7
+ * argus_get_context (fix loop) directly from a conversation without using the CLI.
8
8
  *
9
9
  * Architecture: MCP-inside-MCP
10
10
  * Claude (MCP client)
@@ -33,6 +33,18 @@ import { CdpBrowserAdapter } from './adapters/browser.js';
33
33
 
34
34
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
35
35
 
36
+ // Fix loop: stores up to 20 snapshots keyed by snapshot_id so argus_get_context
37
+ // can diff before/after a fix. Keys are evicted oldest-first when the limit is hit.
38
+ const snapshotStore = new Map();
39
+ const MAX_SNAPSHOTS = 20;
40
+
41
+ function storeSnapshot(id, findings) {
42
+ snapshotStore.set(id, findings);
43
+ if (snapshotStore.size > MAX_SNAPSHOTS) {
44
+ snapshotStore.delete(snapshotStore.keys().next().value);
45
+ }
46
+ }
47
+
36
48
  // ── Tool definitions ─────────────────────────────────────────────────────────
37
49
 
38
50
  const TOOLS = [
@@ -82,11 +94,12 @@ const TOOLS = [
82
94
  },
83
95
  {
84
96
  name: 'argus_get_context',
85
- description: 'Captures everything currently broken on the open Chrome tab and formats it as a diagnostic context for Claude to read and suggest fixes. Does NOT navigate β€” reads the live tab state after user interactions, in authenticated sessions, or mid-flow. Returns { summary, url, timestamp, critical_issues, warnings, js_errors, network_failures, console_errors, recent_requests } where summary is a plain-English description of what is broken. Use when the app is stuck, throwing errors, or behaving unexpectedly β€” run this, then paste the output to Claude and ask for fixes. Requires Chrome on --remote-debugging-port=9222.',
97
+ description: 'Captures everything currently broken on the open Chrome tab and formats it as a diagnostic context for Claude to read and suggest fixes. Does NOT navigate β€” reads the live tab state after user interactions, in authenticated sessions, or mid-flow. Returns { snapshot_id, summary, url, timestamp, critical_issues, warnings, js_errors, network_failures, console_errors, recent_requests }. Fix loop: pass the snapshot_id from a previous call as snapshot_id to get a diff β€” the response will include resolved (cleared since last snapshot), new_issues (appeared since last snapshot), and persisting (unchanged). Workflow: call argus_get_context β†’ Claude suggests fix β†’ apply fix β†’ call argus_get_context with snapshot_id β†’ verify resolved array is non-empty. Requires Chrome on --remote-debugging-port=9222.',
86
98
  inputSchema: {
87
99
  type: 'object',
88
100
  properties: {
89
101
  url: { type: 'string', description: 'Optional base URL to attribute findings to (default: TARGET_DEV_URL env var). Does not navigate β€” inspects the currently open Chrome tab.' },
102
+ snapshot_id: { type: 'string', description: 'Optional snapshot_id from a previous argus_get_context call. When provided, the response includes resolved/new_issues/persisting arrays showing what changed since that snapshot.' },
90
103
  },
91
104
  },
92
105
  },
@@ -143,26 +156,56 @@ async function handleWatchSnapshot({ url } = {}) {
143
156
  });
144
157
  }
145
158
 
146
- async function handleGetContext({ url } = {}) {
159
+ async function handleGetContext({ url, snapshot_id: prevId } = {}) {
147
160
  return withMcp(async (mcp) => {
148
161
  const browser = new CdpBrowserAdapter(mcp);
149
162
  const baseUrl = url ?? process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
150
163
  const session = new WatchSession(browser, baseUrl);
151
164
  const { findings, newConsole, newNetwork } = await session.poll();
152
165
 
166
+ const newId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
167
+ storeSnapshot(newId, findings);
168
+
153
169
  const critical = findings.filter(f => f.severity === 'critical');
154
170
  const warnings = findings.filter(f => f.severity === 'warning');
155
171
 
172
+ const findingKey = (f) => `${f.type}::${(f.message ?? '').slice(0, 120)}`;
173
+
174
+ let resolved = [];
175
+ let persisting = [];
176
+ let new_issues = findings;
177
+ const isDiff = prevId && snapshotStore.has(prevId);
178
+
179
+ if (isDiff) {
180
+ const prev = snapshotStore.get(prevId);
181
+ const prevKeys = new Set(prev.map(findingKey));
182
+ const curKeys = new Set(findings.map(findingKey));
183
+ resolved = prev.filter(f => !curKeys.has(findingKey(f)));
184
+ persisting = findings.filter(f => prevKeys.has(findingKey(f)));
185
+ new_issues = findings.filter(f => !prevKeys.has(findingKey(f)));
186
+ }
187
+
156
188
  let summary;
157
- if (critical.length === 0 && warnings.length === 0) {
189
+ if (isDiff) {
190
+ if (resolved.length > 0 && critical.length === 0 && warnings.length === 0) {
191
+ summary = `All issues resolved on ${baseUrl}. ${resolved.length} finding${resolved.length > 1 ? 's' : ''} cleared since last snapshot.`;
192
+ } else if (resolved.length > 0) {
193
+ summary = `${resolved.length} issue${resolved.length > 1 ? 's' : ''} resolved on ${baseUrl}. ${new_issues.length} new + ${persisting.length} persisting (${critical.length} critical). Pass the new snapshot_id to continue the fix loop.`;
194
+ } else if (critical.length === 0 && warnings.length === 0) {
195
+ summary = `No issues on ${baseUrl} β€” console and network are clean.`;
196
+ } else {
197
+ summary = `No change on ${baseUrl}: ${critical.length} critical + ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} still present. Check the persisting array for what hasn't been fixed.`;
198
+ }
199
+ } else if (critical.length === 0 && warnings.length === 0) {
158
200
  summary = `No issues detected on ${baseUrl} β€” console and network are clean.`;
159
201
  } else if (critical.length > 0) {
160
- summary = `${critical.length} critical issue${critical.length > 1 ? 's' : ''} + ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} detected on ${baseUrl}. Focus on critical issues first.`;
202
+ summary = `${critical.length} critical issue${critical.length > 1 ? 's' : ''} + ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} detected on ${baseUrl}. Focus on critical issues first. Pass snapshot_id to the next argus_get_context call after applying a fix to see what changed.`;
161
203
  } else {
162
- summary = `${warnings.length} warning${warnings.length !== 1 ? 's' : ''} detected on ${baseUrl}. No critical errors.`;
204
+ summary = `${warnings.length} warning${warnings.length !== 1 ? 's' : ''} detected on ${baseUrl}. No critical errors. Pass snapshot_id to the next call to verify fixes.`;
163
205
  }
164
206
 
165
207
  const context = {
208
+ snapshot_id: newId,
166
209
  summary,
167
210
  url: baseUrl,
168
211
  timestamp: new Date().toISOString(),
@@ -172,6 +215,7 @@ async function handleGetContext({ url } = {}) {
172
215
  network_failures: findings.filter(f => f.type === 'network-error' || f.type === 'cors-error' || f.type === 'auth-error'),
173
216
  console_errors: newConsole.filter(m => m.level === 'error' || m.level === 'warning'),
174
217
  recent_requests: newNetwork.slice(-20),
218
+ ...(isDiff ? { resolved, new_issues, persisting } : {}),
175
219
  };
176
220
 
177
221
  return { content: [{ type: 'text', text: JSON.stringify(context, null, 2) }] };
@@ -196,7 +240,7 @@ async function handleLastReport() {
196
240
  // ── Server bootstrap ──────────────────────────────────────────────────────────
197
241
 
198
242
  const server = new Server(
199
- { name: 'argus', version: '9.3.0' },
243
+ { name: 'argus', version: '9.3.1' },
200
244
  { capabilities: { tools: {} } },
201
245
  );
202
246
 
@@ -15,10 +15,12 @@
15
15
  * Environment variables:
16
16
  * ARGUS_WATCH_INTERVAL_MS β€” poll interval in ms (default: 1000)
17
17
  * TARGET_DEV_URL β€” base URL to monitor (default: http://localhost:3000)
18
+ * ARGUS_WATCH_UI_PORT β€” port for the live web dashboard (default: 3002)
18
19
  */
19
20
 
20
21
  import fs from 'fs';
21
22
  import path from 'path';
23
+ import http from 'http';
22
24
  import { fileURLToPath } from 'url';
23
25
  import 'dotenv/config';
24
26
  import { createMcpClient } from '../utils/mcp-client.js';
@@ -41,6 +43,161 @@ import {
41
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
44
  const REPORTS_DIR = path.resolve(__dirname, '../../reports');
43
45
 
46
+ // ── Live dashboard ─────────────────────────────────────────────────────────────
47
+
48
+ const DASHBOARD_HTML = `<!DOCTYPE html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="UTF-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
53
+ <title>Argus Watch Dashboard</title>
54
+ <style>
55
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
56
+ body { background: #0d0d0d; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
57
+ header { background: #111; border-bottom: 1px solid #1e1e1e; padding: 14px 24px; display: flex; align-items: center; gap: 12px; }
58
+ .pulse-dot { width: 10px; height: 10px; border-radius: 50%; background: #5E0ED7; flex-shrink: 0;
59
+ animation: pulse 1.8s ease-in-out infinite; }
60
+ @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(1.3)} }
61
+ .header-text { flex: 1; }
62
+ .header-text h1 { font-size: 15px; font-weight: 600; color: #fff; letter-spacing: .02em; }
63
+ .header-text small { font-size: 11px; color: #666; }
64
+ .pills { display: flex; gap: 8px; flex-wrap: wrap; padding: 16px 24px; }
65
+ .pill { font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 999px; letter-spacing: .04em; }
66
+ .pill-critical { background: #3a0e0e; color: #f87171; border: 1px solid #7f1d1d; }
67
+ .pill-warning { background: #2e2200; color: #fbbf24; border: 1px solid #78350f; }
68
+ .pill-info { background: #1a0b33; color: #a78bfa; border: 1px solid #4c1d95; }
69
+ .pill-clear { background: #0b2718; color: #34d399; border: 1px solid #064e3b; }
70
+ .status-bar { font-size: 11px; padding: 4px 24px 12px; color: #555; }
71
+ .status-bar.error { color: #f87171; }
72
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
73
+ thead th { background: #161616; color: #888; font-weight: 600; text-align: left;
74
+ padding: 10px 16px; border-bottom: 1px solid #222; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
75
+ tbody tr { border-bottom: 1px solid #181818; transition: background .15s; }
76
+ tbody tr:hover { background: #151515; }
77
+ td { padding: 10px 16px; vertical-align: top; }
78
+ .sev { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; white-space: nowrap; }
79
+ .sev-critical { background: #3a0e0e; color: #f87171; }
80
+ .sev-warning { background: #2e2200; color: #fbbf24; }
81
+ .sev-info { background: #1a0b33; color: #a78bfa; }
82
+ .type-cell { color: #9ca3af; font-size: 12px; font-family: monospace; white-space: nowrap; }
83
+ .msg-cell { color: #d1d5db; word-break: break-word; max-width: 480px; }
84
+ .empty-row td { text-align: center; color: #444; padding: 40px; font-size: 13px; }
85
+ .table-wrap { padding: 0 24px 24px; overflow-x: auto; }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <header>
90
+ <div class="pulse-dot" id="dot"></div>
91
+ <div class="header-text">
92
+ <h1 id="target">Argus Watch</h1>
93
+ <small id="lastPoll">Connecting…</small>
94
+ </div>
95
+ </header>
96
+ <div class="pills" id="pills"></div>
97
+ <div class="status-bar" id="status"></div>
98
+ <div class="table-wrap">
99
+ <table>
100
+ <thead><tr><th>Severity</th><th>Type</th><th>Message</th></tr></thead>
101
+ <tbody id="tbody"></tbody>
102
+ </table>
103
+ </div>
104
+ <script>
105
+ const SEV_ORDER = { critical: 0, warning: 1, info: 2 };
106
+
107
+ function renderPills(findings) {
108
+ const counts = { critical: 0, warning: 0, info: 0 };
109
+ for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
110
+ const pills = document.getElementById('pills');
111
+ if (findings.length === 0) {
112
+ pills.innerHTML = '<span class="pill pill-clear">All clear</span>';
113
+ return;
114
+ }
115
+ let html = '';
116
+ if (counts.critical) html += \`<span class="pill pill-critical">\${counts.critical} Critical</span>\`;
117
+ if (counts.warning) html += \`<span class="pill pill-warning">\${counts.warning} Warning</span>\`;
118
+ if (counts.info) html += \`<span class="pill pill-info">\${counts.info} Info</span>\`;
119
+ pills.innerHTML = html;
120
+ }
121
+
122
+ function renderTable(findings) {
123
+ const sorted = [...findings].sort((a, b) =>
124
+ (SEV_ORDER[a.severity] ?? 3) - (SEV_ORDER[b.severity] ?? 3));
125
+ const tbody = document.getElementById('tbody');
126
+ if (sorted.length === 0) {
127
+ tbody.innerHTML = '<tr class="empty-row"><td colspan="3">No findings yet</td></tr>';
128
+ return;
129
+ }
130
+ tbody.innerHTML = sorted.map(f => {
131
+ const sc = 'sev-' + (f.severity || 'info');
132
+ const msg = (f.message || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
133
+ const typ = (f.type || '').replace(/&/g,'&amp;');
134
+ return \`<tr>
135
+ <td><span class="sev \${sc}">\${f.severity ?? 'info'}</span></td>
136
+ <td class="type-cell">\${typ}</td>
137
+ <td class="msg-cell">\${msg}</td>
138
+ </tr>\`;
139
+ }).join('');
140
+ }
141
+
142
+ async function poll() {
143
+ try {
144
+ const res = await fetch('/data');
145
+ if (!res.ok) throw new Error('HTTP ' + res.status);
146
+ const data = await res.json();
147
+ document.getElementById('target').textContent = 'Argus Watch β€” ' + (data.target || '');
148
+ document.getElementById('lastPoll').textContent = 'Last poll: ' + new Date(data.lastPoll).toLocaleTimeString();
149
+ document.getElementById('dot').style.background = '#5E0ED7';
150
+ document.getElementById('status').textContent = '';
151
+ document.getElementById('status').className = 'status-bar';
152
+ renderPills(data.findings || []);
153
+ renderTable(data.findings || []);
154
+ } catch (e) {
155
+ document.getElementById('status').textContent = 'Connection lost β€” ' + e.message;
156
+ document.getElementById('status').className = 'status-bar error';
157
+ document.getElementById('dot').style.background = '#555';
158
+ }
159
+ }
160
+
161
+ poll();
162
+ setInterval(poll, 2000);
163
+ </script>
164
+ </body>
165
+ </html>`;
166
+
167
+ /**
168
+ * Start the live web dashboard HTTP server.
169
+ *
170
+ * @param {() => object[]} getFindings β€” callback that returns current findings array
171
+ * @param {string} target β€” the URL being monitored (shown in header)
172
+ * @param {number} port β€” TCP port to listen on (default 3002)
173
+ * @returns {http.Server}
174
+ */
175
+ function startDashboard(getFindings, target, port) {
176
+ const server = http.createServer((req, res) => {
177
+ if (req.url === '/data' || req.url?.startsWith('/data?')) {
178
+ const payload = JSON.stringify({
179
+ target,
180
+ lastPoll: new Date().toISOString(),
181
+ findings: getFindings(),
182
+ });
183
+ res.writeHead(200, {
184
+ 'Content-Type': 'application/json',
185
+ 'Access-Control-Allow-Origin': '*',
186
+ });
187
+ res.end(payload);
188
+ } else {
189
+ res.writeHead(200, { 'Content-Type': 'text/html' });
190
+ res.end(DASHBOARD_HTML);
191
+ }
192
+ });
193
+
194
+ server.listen(port, '127.0.0.1', () => {
195
+ logger.info(`[ARGUS WATCH] Dashboard β†’ http://localhost:${port}`);
196
+ });
197
+
198
+ return server;
199
+ }
200
+
44
201
  // ── Deduplication key generators ───────────────────────────────────────────────
45
202
  // Two messages/requests are considered "the same" if their keys match. This
46
203
  // prevents re-reporting errors that were already captured in a previous poll.
@@ -228,6 +385,9 @@ export async function runWatchMode(baseUrl) {
228
385
  logger.info(`[ARGUS WATCH] Polling every ${pollIntervalMs}ms. Press Ctrl+C to stop.`);
229
386
  logger.info('[ARGUS WATCH] ─────────────────────────────────────────────────\n');
230
387
 
388
+ const uiPort = parseInt(process.env.ARGUS_WATCH_UI_PORT ?? '3002', 10);
389
+ const dashServer = startDashboard(() => session.getAllFindings(), target, uiPort);
390
+
231
391
  const badge = (severity) =>
232
392
  severity === 'critical' ? 'βœ— CRIT' :
233
393
  severity === 'warning' ? '! WARN' : 'i INFO';
@@ -270,6 +430,7 @@ export async function runWatchMode(baseUrl) {
270
430
 
271
431
  process.on('SIGINT', async () => {
272
432
  clearInterval(interval);
433
+ try { dashServer.close(); } catch { /* ignore */ }
273
434
  const all = session.getAllFindings();
274
435
 
275
436
  logger.info(`\n[ARGUS WATCH] Stopped. Total findings: ${all.length}`);