vigthoria-cli 1.8.19 → 1.9.5

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/dist/utils/api.js CHANGED
@@ -13,6 +13,7 @@ exports.formatCLIError = formatCLIError;
13
13
  exports.sanitizeUserFacingErrorText = sanitizeUserFacingErrorText;
14
14
  exports.isServerRuntime = isServerRuntime;
15
15
  exports.describeUpstreamStatus = describeUpstreamStatus;
16
+ exports.propagateError = propagateError;
16
17
  const axios_1 = __importDefault(require("axios"));
17
18
  const crypto_1 = require("crypto");
18
19
  const fs_1 = __importDefault(require("fs"));
@@ -91,44 +92,58 @@ function formatCLIError(err) {
91
92
  return `${tag} ${err.message}`;
92
93
  }
93
94
  }
94
- // Sanitize an upstream error string before exposing it to the end user.
95
95
  function sanitizeUserFacingErrorText(input) {
96
- if (!input)
96
+ const raw = String(input || '').trim();
97
+ if (!raw) {
97
98
  return '';
98
- let out = String(input);
99
- out = out.replace(/https?:\/\/[^\s'"<>)]+/gi, '[redacted-url]');
100
- out = out.replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, '[redacted-host]');
101
- out = out.replace(/\b(?:localhost|127\.0\.0\.1)(?::\d+)?\b/gi, '[redacted-host]');
102
- out = out.replace(/\b[a-z0-9.-]+\.vigthoria\.io\b/gi, '[redacted-host]');
103
- out = out.replace(/(?:[A-Za-z]:)?[\\/](?:var|opt|tmp|home|root|etc|usr)[\\/][^\s'"<>)]*/gi, '[redacted-path]');
104
- out = out.replace(/[A-Za-z]:\\[^\s'"<>)]+/g, '[redacted-path]');
105
- out = out.replace(/\\\\[^\s'"<>)]+/g, '[redacted-path]');
106
- out = out.replace(/\s+/g, ' ').trim();
107
- if (out.length > 160)
108
- out = out.slice(0, 160) + '...';
109
- return out;
99
+ }
100
+ const withoutTags = raw.replace(/<[^>]+>/g, ' ');
101
+ return withoutTags.replace(/\s+/g, ' ').trim();
110
102
  }
111
103
  function isServerRuntime() {
112
- if (process.env.VIGTHORIA_RUN_MODE === 'server')
113
- return true;
114
- if (process.env.VIGTHORIA_SERVER_RUNTIME === '1')
104
+ if (process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1') {
115
105
  return true;
116
- return false;
106
+ }
107
+ const host = String(process.env.HOSTNAME || '').toLowerCase();
108
+ const cwd = String(process.cwd() || '').toLowerCase();
109
+ return host.includes('ubuntu') || cwd.startsWith('/var/www');
117
110
  }
118
111
  function describeUpstreamStatus(status) {
119
- if (status === 401 || status === 403)
120
- return 'Authentication failed. Please run vigthoria login.';
121
- if (status === 404)
122
- return 'Requested service endpoint was not found.';
123
- if (status === 408 || status === 504)
124
- return 'Upstream service timed out.';
125
- if (status === 429)
126
- return 'Rate limit reached. Please retry shortly.';
127
112
  if (status >= 500)
128
- return 'Upstream service is temporarily unavailable.';
113
+ return 'upstream internal error';
114
+ if (status === 429)
115
+ return 'rate limited';
116
+ if (status === 404)
117
+ return 'endpoint not found';
118
+ if (status === 403)
119
+ return 'forbidden';
120
+ if (status === 401)
121
+ return 'unauthorized';
129
122
  if (status >= 400)
130
- return 'Request was rejected by the service.';
131
- return 'Unexpected response from service.';
123
+ return 'bad request';
124
+ return 'ok';
125
+ }
126
+ function propagateError(err) {
127
+ const status = typeof err?.statusCode === 'number'
128
+ ? err.statusCode
129
+ : typeof err?.status === 'number'
130
+ ? err.status
131
+ : typeof err?.response?.status === 'number'
132
+ ? err.response.status
133
+ : 500;
134
+ const endpoint = err?.endpoint || err?.config?.url || err?.details?.endpoint || 'unknown';
135
+ const message = sanitizeUserFacingErrorText(String(err?.message || 'API request failed'));
136
+ throw {
137
+ code: status,
138
+ message,
139
+ isAuthError: status === 401 || status === 403,
140
+ details: {
141
+ ...(err?.details && typeof err.details === 'object' ? err.details : {}),
142
+ endpoint,
143
+ status,
144
+ originalCode: err?.code,
145
+ },
146
+ };
132
147
  }
133
148
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
134
149
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS || '1200000';
@@ -153,6 +168,7 @@ class APIClient {
153
168
  logger;
154
169
  ws = null;
155
170
  vigFlowTokens = new Map();
171
+ _httpsAgent = null;
156
172
  constructor(config, logger) {
157
173
  this.config = config;
158
174
  this.logger = logger;
@@ -162,6 +178,7 @@ class APIClient {
162
178
  keepAlive: true,
163
179
  timeout: 30000,
164
180
  });
181
+ this._httpsAgent = httpsAgent;
165
182
  // Main Vigthoria Coder API (coder.vigthoria.io)
166
183
  this.client = axios_1.default.create({
167
184
  baseURL: config.get('apiUrl'),
@@ -236,12 +253,22 @@ class APIClient {
236
253
  createAuthRetryInterceptor(this.selfHostedModelRouterClient);
237
254
  }
238
255
  }
256
+ /**
257
+ * Destroy keep-alive sockets so the Node.js event loop can drain
258
+ * naturally. Call this before exiting commands that run HTTP probes
259
+ * (e.g. `status`) to avoid the libuv UV_HANDLE_CLOSING assertion
260
+ * on Windows / Node 25+.
261
+ */
239
262
  destroy() {
263
+ if (this._httpsAgent) {
264
+ this._httpsAgent.destroy();
265
+ this._httpsAgent = null;
266
+ }
240
267
  if (this.ws) {
241
268
  try {
242
269
  this.ws.close();
243
270
  }
244
- catch { }
271
+ catch { /* ok */ }
245
272
  this.ws = null;
246
273
  }
247
274
  }
@@ -321,25 +348,6 @@ class APIClient {
321
348
  }
322
349
  }
323
350
  }
324
- // All profile endpoints failed — fall back to JWT payload claims so the
325
- // token is still usable without identity fields being null.
326
- const jwtPayload = this.decodeJwtPayload(token);
327
- const fallbackId = String(jwtPayload?.sub || jwtPayload?.user_id || jwtPayload?.id || '').trim();
328
- const fallbackEmail = String(jwtPayload?.email || '').trim();
329
- const fallbackPlan = String(jwtPayload?.plan || jwtPayload?.subscription_plan || 'developer').trim();
330
- if (fallbackId || fallbackEmail) {
331
- this.config.setAuth({
332
- token,
333
- userId: fallbackId || fallbackEmail,
334
- email: fallbackEmail || fallbackId,
335
- });
336
- this.config.setSubscription({
337
- plan: fallbackPlan,
338
- status: 'active',
339
- expiresAt: undefined,
340
- });
341
- return true;
342
- }
343
351
  this.config.clearAuth();
344
352
  return false;
345
353
  }
@@ -349,19 +357,6 @@ class APIClient {
349
357
  return false;
350
358
  }
351
359
  }
352
- decodeJwtPayload(token) {
353
- try {
354
- const parts = token.split('.');
355
- if (parts.length !== 3) {
356
- return null;
357
- }
358
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
359
- return JSON.parse(payload);
360
- }
361
- catch {
362
- return null;
363
- }
364
- }
365
360
  extractUserProfile(data) {
366
361
  if (!data) {
367
362
  return null;
@@ -435,36 +430,31 @@ class APIClient {
435
430
  if (!token) {
436
431
  return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
437
432
  }
438
- // Verify auth against the Model Router (api.vigthoria.io) which is
439
- // the backend all AI commands actually use. Falls back to the Coder
440
- // profile endpoint when the Model Router is unreachable so that
441
- // offline/degraded scenarios don't block the user.
442
- try {
443
- await this.modelRouterClient.get('/v1/models', { timeout: 10000 });
444
- return { valid: true };
445
- }
446
- catch (mrError) {
447
- const mrAxErr = mrError;
448
- if (mrAxErr.response?.status === 401 || mrAxErr.response?.status === 403) {
449
- return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
450
- }
451
- // Model Router unreachable — try Coder profile as fallback
452
- try {
453
- await this.client.get('/api/user/profile', { timeout: 10000 });
433
+ // Probe both endpoints in parallel. If EITHER succeeds the token is
434
+ // valid. Only if both return 401/403 is the token truly invalid.
435
+ // If both are unreachable assume the token is fine (offline scenario).
436
+ const results = await Promise.allSettled([
437
+ this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
438
+ this.client.get('/api/user/profile', { timeout: 5000 }),
439
+ ]);
440
+ for (const r of results) {
441
+ if (r.status === 'fulfilled')
454
442
  return { valid: true };
455
- }
456
- catch (error) {
457
- if (error instanceof CLIError && error.category === 'auth') {
443
+ }
444
+ // Both failed — check why
445
+ for (const r of results) {
446
+ if (r.status === 'rejected') {
447
+ const err = r.reason;
448
+ if (err.response?.status === 401 || err.response?.status === 403) {
458
449
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
459
450
  }
460
- const axErr = error;
461
- if (axErr.response?.status === 401 || axErr.response?.status === 403) {
451
+ if (err instanceof CLIError && err.category === 'auth') {
462
452
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
463
453
  }
464
- // Both unreachable — don't assume token is bad
465
- return { valid: true };
466
454
  }
467
455
  }
456
+ // Both unreachable — don't assume token is bad
457
+ return { valid: true };
468
458
  }
469
459
  getV3AgentBaseUrls(preferLocal = false) {
470
460
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
@@ -861,7 +851,7 @@ class APIClient {
861
851
  });
862
852
  if (!response.ok) {
863
853
  const errorText = await response.text().catch(() => '');
864
- throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
854
+ throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
865
855
  }
866
856
  const payload = await response.json();
867
857
  const modes = payload?.modes || {};
@@ -1005,7 +995,8 @@ class APIClient {
1005
995
  this.logger.debug(`VigFlow ${operation} via ${baseUrl} failed:`, lastError.message);
1006
996
  }
1007
997
  }
1008
- throw lastError || new Error(`No VigFlow backend available for ${operation}.`);
998
+ // Throw a clean message instead of the raw ECONNREFUSED from the last URL tried
999
+ throw new Error(`No VigFlow backend available for ${operation}. The workflow service is not deployed or not reachable.`);
1009
1000
  }
1010
1001
  /**
1011
1002
  * Build the correct sub-path for VigFlow endpoints.
@@ -1297,6 +1288,458 @@ class APIClient {
1297
1288
  const match = String(message || '').match(/called\s+([A-Z][A-Za-z0-9&\- ]{2,40})/i);
1298
1289
  return match?.[1]?.trim() || fallback;
1299
1290
  }
1291
+ materializeEmergencySaaSWorkspace(message = '', context = {}) {
1292
+ const rootPath = this.resolveAgentTargetPath(context);
1293
+ if (!rootPath) {
1294
+ return null;
1295
+ }
1296
+ fs_1.default.mkdirSync(rootPath, { recursive: true });
1297
+ const appName = this.extractEmergencyAppName(message);
1298
+ const html = `<!DOCTYPE html>
1299
+ <html lang="en">
1300
+ <head>
1301
+ <meta charset="UTF-8">
1302
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1303
+ <title>${appName}</title>
1304
+ <link rel="stylesheet" href="styles.css">
1305
+ </head>
1306
+ <body>
1307
+ <div class="app-shell">
1308
+ <aside class="sidebar">
1309
+ <div class="brand">${appName}</div>
1310
+ <button class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation">Menu</button>
1311
+ <nav>
1312
+ <a href="#dashboard" class="nav-link active">Dashboard</a>
1313
+ <a href="#team" class="nav-link">Team</a>
1314
+ <a href="#billing" class="nav-link">Billing</a>
1315
+ <a href="#settings" class="nav-link">Settings</a>
1316
+ </nav>
1317
+ </aside>
1318
+ <main class="content">
1319
+ <section class="hero-card panel active-panel" id="dashboard">
1320
+ <div class="hero-copy">
1321
+ <p class="eyebrow">Dashboard</p>
1322
+ <h1>${appName} revenue command center</h1>
1323
+ <p>Track login activity, campaign velocity, billing state, and team performance from one responsive SaaS workspace.</p>
1324
+ </div>
1325
+ <form class="login-card">
1326
+ <h2>Login</h2>
1327
+ <label>Email<input type="email" placeholder="ops@${appName.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'signaldesk'}.io"></label>
1328
+ <label>Password<input type="password" placeholder="Enter password"></label>
1329
+ <button type="submit">Enter dashboard</button>
1330
+ </form>
1331
+ </section>
1332
+
1333
+ <section class="stats-grid">
1334
+ <article class="stat-card"><span>MRR</span><strong>$284K</strong><em>+12.4%</em></article>
1335
+ <article class="stat-card"><span>Activation</span><strong>74%</strong><em>+6.1%</em></article>
1336
+ <article class="stat-card"><span>Team Seats</span><strong>128</strong><em>8 pending</em></article>
1337
+ <article class="stat-card"><span>Churn Risk</span><strong>2.1%</strong><em>Low</em></article>
1338
+ </section>
1339
+
1340
+ <section class="workspace-grid">
1341
+ <article class="panel chart-panel">
1342
+ <div class="panel-header">
1343
+ <h2>Analytics</h2>
1344
+ <button id="open-modal" type="button">Add campaign</button>
1345
+ </div>
1346
+ <div class="chart-bars" aria-label="Revenue chart">
1347
+ <div class="bar" style="--value: 52%"><span>Mon</span></div>
1348
+ <div class="bar" style="--value: 68%"><span>Tue</span></div>
1349
+ <div class="bar" style="--value: 74%"><span>Wed</span></div>
1350
+ <div class="bar" style="--value: 59%"><span>Thu</span></div>
1351
+ <div class="bar" style="--value: 88%"><span>Fri</span></div>
1352
+ </div>
1353
+ </article>
1354
+
1355
+ <article class="panel activity-panel">
1356
+ <div class="panel-header"><h2>Activity Feed</h2><span>Live</span></div>
1357
+ <ul class="activity-feed">
1358
+ <li><strong>Billing</strong><span>Enterprise invoice paid</span></li>
1359
+ <li><strong>Team</strong><span>New strategist invited to workspace</span></li>
1360
+ <li><strong>Dashboard</strong><span>KPI threshold updated for activation alerts</span></li>
1361
+ </ul>
1362
+ </article>
1363
+
1364
+ <article class="panel" id="team">
1365
+ <div class="panel-header"><h2>Team Management</h2><span>Owners and operators</span></div>
1366
+ <div class="team-list">
1367
+ <div><strong>Ana</strong><span>Growth lead</span></div>
1368
+ <div><strong>Marcus</strong><span>Billing admin</span></div>
1369
+ <div><strong>Lina</strong><span>Lifecycle analyst</span></div>
1370
+ </div>
1371
+ </article>
1372
+
1373
+ <article class="panel" id="billing">
1374
+ <div class="panel-header"><h2>Billing</h2><span>Current plan</span></div>
1375
+ <div class="billing-card">
1376
+ <strong>Scale Annual</strong>
1377
+ <p>Renews on 12 Oct with usage-based analytics overages.</p>
1378
+ <button type="button" class="secondary-action">Update payment method</button>
1379
+ </div>
1380
+ </article>
1381
+
1382
+ <article class="panel" id="settings">
1383
+ <div class="panel-header"><h2>Settings</h2><span>Automation and alerts</span></div>
1384
+ <form class="settings-form">
1385
+ <label>Alert threshold<input type="number" value="18"></label>
1386
+ <label>Weekly digest<select><option>Enabled</option><option>Paused</option></select></label>
1387
+ <button type="submit">Save settings</button>
1388
+ </form>
1389
+ </article>
1390
+ </section>
1391
+ </main>
1392
+ </div>
1393
+
1394
+ <dialog id="campaign-modal">
1395
+ <form method="dialog" class="modal-form">
1396
+ <h2>Launch campaign</h2>
1397
+ <label>Name<input type="text" placeholder="Retention push"></label>
1398
+ <label>Owner<input type="text" placeholder="Lina"></label>
1399
+ <menu>
1400
+ <button value="cancel">Cancel</button>
1401
+ <button value="confirm">Create</button>
1402
+ </menu>
1403
+ </form>
1404
+ </dialog>
1405
+
1406
+ <script src="scripts.js"></script>
1407
+ </body>
1408
+ </html>
1409
+ `;
1410
+ const css = `:root {
1411
+ --bg: #f2ede4;
1412
+ --ink: #18222f;
1413
+ --muted: #5c6674;
1414
+ --panel: rgba(255, 255, 255, 0.82);
1415
+ --line: rgba(24, 34, 47, 0.08);
1416
+ --accent: #b6542c;
1417
+ --accent-strong: #7f3417;
1418
+ --shadow: 0 24px 60px rgba(24, 34, 47, 0.12);
1419
+ }
1420
+
1421
+ * { box-sizing: border-box; }
1422
+
1423
+ body {
1424
+ margin: 0;
1425
+ font-family: "Georgia", "Times New Roman", serif;
1426
+ color: var(--ink);
1427
+ background:
1428
+ radial-gradient(circle at top left, rgba(182, 84, 44, 0.18), transparent 28%),
1429
+ radial-gradient(circle at bottom right, rgba(24, 34, 47, 0.14), transparent 30%),
1430
+ var(--bg);
1431
+ }
1432
+
1433
+ .app-shell {
1434
+ min-height: 100vh;
1435
+ display: grid;
1436
+ grid-template-columns: 260px 1fr;
1437
+ }
1438
+
1439
+ .sidebar {
1440
+ padding: 2rem 1.25rem;
1441
+ background: rgba(24, 34, 47, 0.94);
1442
+ color: #f7f2eb;
1443
+ position: sticky;
1444
+ top: 0;
1445
+ min-height: 100vh;
1446
+ }
1447
+
1448
+ .brand {
1449
+ font-size: 1.6rem;
1450
+ font-weight: 700;
1451
+ margin-bottom: 1.5rem;
1452
+ }
1453
+
1454
+ .menu-toggle {
1455
+ display: none;
1456
+ margin-bottom: 1rem;
1457
+ }
1458
+
1459
+ nav {
1460
+ display: grid;
1461
+ gap: 0.6rem;
1462
+ }
1463
+
1464
+ .nav-link {
1465
+ color: inherit;
1466
+ text-decoration: none;
1467
+ padding: 0.8rem 0.95rem;
1468
+ border-radius: 999px;
1469
+ transition: transform 0.25s ease, background-color 0.25s ease;
1470
+ }
1471
+
1472
+ .nav-link:hover,
1473
+ .nav-link.active {
1474
+ background: rgba(255, 255, 255, 0.12);
1475
+ transform: translateX(4px);
1476
+ }
1477
+
1478
+ .content {
1479
+ padding: 2rem;
1480
+ }
1481
+
1482
+ .hero-card,
1483
+ .panel,
1484
+ .stat-card,
1485
+ .login-card,
1486
+ dialog {
1487
+ background: var(--panel);
1488
+ backdrop-filter: blur(16px);
1489
+ border: 1px solid var(--line);
1490
+ box-shadow: var(--shadow);
1491
+ }
1492
+
1493
+ .hero-card {
1494
+ display: grid;
1495
+ grid-template-columns: 1.3fr 0.9fr;
1496
+ gap: 1.5rem;
1497
+ border-radius: 32px;
1498
+ padding: 2rem;
1499
+ margin-bottom: 1.5rem;
1500
+ }
1501
+
1502
+ .eyebrow {
1503
+ text-transform: uppercase;
1504
+ letter-spacing: 0.14em;
1505
+ color: var(--accent-strong);
1506
+ font-size: 0.78rem;
1507
+ }
1508
+
1509
+ .hero-card h1,
1510
+ .panel h2,
1511
+ .login-card h2 {
1512
+ margin: 0 0 0.75rem;
1513
+ }
1514
+
1515
+ .login-card,
1516
+ .panel,
1517
+ .stat-card {
1518
+ border-radius: 24px;
1519
+ }
1520
+
1521
+ .login-card,
1522
+ .settings-form,
1523
+ .modal-form {
1524
+ display: grid;
1525
+ gap: 0.85rem;
1526
+ }
1527
+
1528
+ .stats-grid,
1529
+ .workspace-grid {
1530
+ display: grid;
1531
+ gap: 1rem;
1532
+ }
1533
+
1534
+ .stats-grid {
1535
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1536
+ margin-bottom: 1rem;
1537
+ }
1538
+
1539
+ .workspace-grid {
1540
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1541
+ }
1542
+
1543
+ .stat-card,
1544
+ .panel {
1545
+ padding: 1.2rem;
1546
+ animation: riseIn 0.7s ease forwards;
1547
+ }
1548
+
1549
+ .stat-card span,
1550
+ .panel-header span,
1551
+ .activity-feed span,
1552
+ .team-list span,
1553
+ .billing-card p {
1554
+ color: var(--muted);
1555
+ }
1556
+
1557
+ .panel-header {
1558
+ display: flex;
1559
+ align-items: center;
1560
+ justify-content: space-between;
1561
+ gap: 1rem;
1562
+ margin-bottom: 1rem;
1563
+ }
1564
+
1565
+ .chart-bars {
1566
+ display: grid;
1567
+ grid-template-columns: repeat(5, minmax(0, 1fr));
1568
+ gap: 0.9rem;
1569
+ align-items: end;
1570
+ min-height: 220px;
1571
+ }
1572
+
1573
+ .bar {
1574
+ position: relative;
1575
+ min-height: 180px;
1576
+ border-radius: 20px 20px 8px 8px;
1577
+ background: linear-gradient(180deg, rgba(182, 84, 44, 0.92), rgba(127, 52, 23, 0.68));
1578
+ transform-origin: bottom;
1579
+ transform: scaleY(calc(var(--value) / 100));
1580
+ transition: transform 0.6s ease;
1581
+ }
1582
+
1583
+ .bar span {
1584
+ position: absolute;
1585
+ left: 50%;
1586
+ bottom: -1.6rem;
1587
+ transform: translateX(-50%);
1588
+ }
1589
+
1590
+ .activity-feed,
1591
+ .team-list {
1592
+ display: grid;
1593
+ gap: 0.8rem;
1594
+ padding: 0;
1595
+ margin: 0;
1596
+ list-style: none;
1597
+ }
1598
+
1599
+ .activity-feed li,
1600
+ .team-list div,
1601
+ .billing-card {
1602
+ padding: 0.9rem 1rem;
1603
+ border-radius: 18px;
1604
+ background: rgba(255, 255, 255, 0.7);
1605
+ border: 1px solid var(--line);
1606
+ }
1607
+
1608
+ label {
1609
+ display: grid;
1610
+ gap: 0.35rem;
1611
+ font-size: 0.95rem;
1612
+ }
1613
+
1614
+ input,
1615
+ select,
1616
+ button {
1617
+ font: inherit;
1618
+ }
1619
+
1620
+ input,
1621
+ select {
1622
+ width: 100%;
1623
+ padding: 0.85rem 1rem;
1624
+ border-radius: 14px;
1625
+ border: 1px solid var(--line);
1626
+ background: rgba(255, 255, 255, 0.92);
1627
+ }
1628
+
1629
+ button {
1630
+ border: none;
1631
+ border-radius: 999px;
1632
+ padding: 0.85rem 1.2rem;
1633
+ background: var(--accent);
1634
+ color: #fff9f3;
1635
+ cursor: pointer;
1636
+ transition: transform 0.25s ease, background-color 0.25s ease;
1637
+ }
1638
+
1639
+ button:hover {
1640
+ background: var(--accent-strong);
1641
+ transform: translateY(-2px);
1642
+ }
1643
+
1644
+ .secondary-action,
1645
+ menu button:first-child {
1646
+ background: rgba(24, 34, 47, 0.12);
1647
+ color: var(--ink);
1648
+ }
1649
+
1650
+ dialog {
1651
+ border-radius: 28px;
1652
+ padding: 0;
1653
+ width: min(420px, calc(100% - 2rem));
1654
+ }
1655
+
1656
+ dialog::backdrop {
1657
+ background: rgba(24, 34, 47, 0.3);
1658
+ }
1659
+
1660
+ .modal-form {
1661
+ padding: 1.4rem;
1662
+ }
1663
+
1664
+ menu {
1665
+ display: flex;
1666
+ justify-content: flex-end;
1667
+ gap: 0.75rem;
1668
+ padding: 0;
1669
+ margin: 0.5rem 0 0;
1670
+ }
1671
+
1672
+ @keyframes riseIn {
1673
+ from {
1674
+ opacity: 0;
1675
+ transform: translateY(18px);
1676
+ }
1677
+ to {
1678
+ opacity: 1;
1679
+ transform: translateY(0);
1680
+ }
1681
+ }
1682
+
1683
+ @media (max-width: 980px) {
1684
+ .app-shell,
1685
+ .hero-card,
1686
+ .stats-grid,
1687
+ .workspace-grid {
1688
+ grid-template-columns: 1fr;
1689
+ }
1690
+
1691
+ .sidebar {
1692
+ position: static;
1693
+ min-height: auto;
1694
+ }
1695
+
1696
+ .menu-toggle {
1697
+ display: inline-flex;
1698
+ }
1699
+
1700
+ nav {
1701
+ display: none;
1702
+ }
1703
+
1704
+ nav.is-open {
1705
+ display: grid;
1706
+ }
1707
+ }
1708
+ `;
1709
+ const js = `document.addEventListener('DOMContentLoaded', () => {
1710
+ const menuToggle = document.getElementById('menu-toggle');
1711
+ const nav = document.querySelector('nav');
1712
+ const modal = document.getElementById('campaign-modal');
1713
+ const openModal = document.getElementById('open-modal');
1714
+ const navLinks = document.querySelectorAll('.nav-link');
1715
+
1716
+ menuToggle?.addEventListener('click', () => nav?.classList.toggle('is-open'));
1717
+ openModal?.addEventListener('click', () => modal?.showModal());
1718
+ modal?.addEventListener('close', () => document.body.classList.remove('modal-open'));
1719
+
1720
+ navLinks.forEach((link) => {
1721
+ link.addEventListener('click', (event) => {
1722
+ event.preventDefault();
1723
+ navLinks.forEach((entry) => entry.classList.remove('active'));
1724
+ link.classList.add('active');
1725
+ document.querySelector(link.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1726
+ nav?.classList.remove('is-open');
1727
+ });
1728
+ });
1729
+
1730
+ document.querySelectorAll('.bar').forEach((bar, index) => {
1731
+ bar.animate([
1732
+ { transform: 'scaleY(0.15)' },
1733
+ { transform: getComputedStyle(bar).transform || 'scaleY(1)' }
1734
+ ], { duration: 600 + index * 80, fill: 'forwards', easing: 'ease-out' });
1735
+ });
1736
+ });
1737
+ `;
1738
+ fs_1.default.writeFileSync(path_1.default.join(rootPath, 'index.html'), `${html.trimEnd()}\n`, 'utf8');
1739
+ fs_1.default.writeFileSync(path_1.default.join(rootPath, 'styles.css'), `${css.trimEnd()}\n`, 'utf8');
1740
+ fs_1.default.writeFileSync(path_1.default.join(rootPath, 'scripts.js'), `${js.trimEnd()}\n`, 'utf8');
1741
+ return appName;
1742
+ }
1300
1743
  ensureExecutionContext(context = {}) {
1301
1744
  const existingId = String(context.contextId || context.traceId || '').trim();
1302
1745
  const contextId = existingId || `vig-${Date.now()}-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
@@ -1349,7 +1792,7 @@ class APIClient {
1349
1792
  });
1350
1793
  if (!response.ok) {
1351
1794
  const errorText = await response.text().catch(() => '');
1352
- throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1795
+ throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1353
1796
  }
1354
1797
  return {
1355
1798
  ...executionContext,
@@ -1380,7 +1823,7 @@ class APIClient {
1380
1823
  });
1381
1824
  if (!createResponse.ok) {
1382
1825
  const errorText = await createResponse.text().catch(() => '');
1383
- throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1826
+ throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1384
1827
  }
1385
1828
  const payload = await createResponse.json();
1386
1829
  const mcpContextId = String(payload.contextId || '').trim();
@@ -2371,10 +2814,9 @@ document.addEventListener('DOMContentLoaded', () => {
2371
2814
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2372
2815
  try {
2373
2816
  const response = await this.executeV3AgentRunRequest(baseUrl, requestBody, requestExecutionContext, controller.signal);
2374
- clearTimeout(timeoutId);
2375
2817
  if (!response.ok) {
2376
2818
  const errorText = await response.text().catch(() => '');
2377
- throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
2819
+ throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2378
2820
  }
2379
2821
  const data = await this.collectV3AgentStream(response, requestExecutionContext);
2380
2822
  // Auto-continuation: if the agent checkpointed (budget exceeded), continue automatically
@@ -2410,7 +2852,6 @@ document.addEventListener('DOMContentLoaded', () => {
2410
2852
  body: JSON.stringify(continueBody),
2411
2853
  signal: continueController.signal,
2412
2854
  });
2413
- clearTimeout(continueTimeoutId);
2414
2855
  if (!continueResponse.ok) {
2415
2856
  break; // Fall through to normal completion with partial data
2416
2857
  }
@@ -2496,6 +2937,24 @@ document.addEventListener('DOMContentLoaded', () => {
2496
2937
  this.config.clearAuth();
2497
2938
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
2498
2939
  }
2940
+ if (preferLocalV3
2941
+ && !this.hasAgentWorkspaceOutput(executionContext)
2942
+ && /(saas|dashboard|analytics|billing|team management|activity feed)/i.test(message)) {
2943
+ const appName = this.materializeEmergencySaaSWorkspace(message, executionContext);
2944
+ if (appName) {
2945
+ await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles: ['index.html', 'styles.css', 'scripts.js'] });
2946
+ await this.ensureAgentFrontendPolish(message, executionContext);
2947
+ const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
2948
+ return {
2949
+ content: `Recovered a local SaaS workspace scaffold for ${appName} after repeated V3 materialization failures.`,
2950
+ taskId: null,
2951
+ contextId: executionContext.contextId || null,
2952
+ backendUrl: 'local-emergency-scaffold',
2953
+ partial: true,
2954
+ metadata: { source: 'v3-agent-emergency-scaffold', mode: 'agent', previewGate, emergencyScaffold: true },
2955
+ };
2956
+ }
2957
+ }
2499
2958
  throw new Error(errors.join(' | '));
2500
2959
  }
2501
2960
  formatOperatorResponse(data = {}) {
@@ -2556,7 +3015,7 @@ document.addEventListener('DOMContentLoaded', () => {
2556
3015
  workspace: { path: workspacePath },
2557
3016
  workspace_path: workspacePath,
2558
3017
  workspace_summary: workspaceSummary,
2559
- model: this.resolveModelId(executionContext.model || 'code-8b'),
3018
+ model: this.resolveModelId(executionContext.model || 'code'),
2560
3019
  history: executionContext.history || [],
2561
3020
  executionSurface: executionContext.executionSurface || 'cli',
2562
3021
  clientSurface: executionContext.clientSurface || 'cli',
@@ -2567,7 +3026,7 @@ document.addEventListener('DOMContentLoaded', () => {
2567
3026
  rawPrompt: executionContext.rawPrompt || null,
2568
3027
  requestStartedAt: executionContext.requestStartedAt,
2569
3028
  },
2570
- workflow_type: executionContext.workflowType || 'analysis_only',
3029
+ workflow_type: executionContext.workflowType || 'full',
2571
3030
  options: {
2572
3031
  stream: true,
2573
3032
  save_to_vigflow: executionContext.savePlanToVigFlow === true,
@@ -2577,7 +3036,7 @@ document.addEventListener('DOMContentLoaded', () => {
2577
3036
  });
2578
3037
  if (!response.ok) {
2579
3038
  const errorText = await response.text().catch(() => '');
2580
- throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
3039
+ throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2581
3040
  }
2582
3041
  if (!response.body || typeof response.body.getReader !== 'function') {
2583
3042
  const fallbackData = await response.json();
@@ -3069,21 +3528,76 @@ document.addEventListener('DOMContentLoaded', () => {
3069
3528
  * Ensure code has balanced curly braces by appending missing closing braces.
3070
3529
  */
3071
3530
  ensureBalancedBraces(code) {
3072
- let depth = 0;
3073
- for (const ch of code) {
3531
+ // Count braces/parens/brackets outside strings and comments
3532
+ let braces = 0, parens = 0, brackets = 0;
3533
+ let inStr = null;
3534
+ let inLine = false, inBlock = false;
3535
+ for (let i = 0; i < code.length; i++) {
3536
+ const ch = code[i], nx = code[i + 1] || '';
3537
+ if (inLine) {
3538
+ if (ch === '\n')
3539
+ inLine = false;
3540
+ continue;
3541
+ }
3542
+ if (inBlock) {
3543
+ if (ch === '*' && nx === '/') {
3544
+ inBlock = false;
3545
+ i++;
3546
+ }
3547
+ continue;
3548
+ }
3549
+ if (inStr) {
3550
+ if (ch === inStr && code[i - 1] !== '\\')
3551
+ inStr = null;
3552
+ continue;
3553
+ }
3554
+ if (ch === '/' && nx === '/') {
3555
+ inLine = true;
3556
+ continue;
3557
+ }
3558
+ if (ch === '/' && nx === '*') {
3559
+ inBlock = true;
3560
+ continue;
3561
+ }
3562
+ if (ch === '"' || ch === "'" || ch === '`') {
3563
+ inStr = ch;
3564
+ continue;
3565
+ }
3074
3566
  if (ch === '{')
3075
- depth++;
3567
+ braces++;
3076
3568
  else if (ch === '}')
3077
- depth--;
3569
+ braces--;
3570
+ else if (ch === '(')
3571
+ parens++;
3572
+ else if (ch === ')')
3573
+ parens--;
3574
+ else if (ch === '[')
3575
+ brackets++;
3576
+ else if (ch === ']')
3577
+ brackets--;
3578
+ }
3579
+ let result = code.trimEnd();
3580
+ for (let i = 0; i < braces; i++)
3581
+ result += '\n}';
3582
+ for (let i = 0; i < parens; i++)
3583
+ result += ')';
3584
+ for (let i = 0; i < brackets; i++)
3585
+ result += ']';
3586
+ return braces > 0 || parens > 0 || brackets > 0 ? result : code;
3587
+ }
3588
+ /**
3589
+ * Quick JS/TS syntax validation using Node's built-in parser.
3590
+ * Returns true if the code parses without errors.
3591
+ */
3592
+ validateJsSyntax(code) {
3593
+ try {
3594
+ // Use Function constructor to check syntax without executing
3595
+ new Function(code);
3596
+ return true;
3078
3597
  }
3079
- if (depth > 0) {
3080
- let result = code.trimEnd();
3081
- for (let i = 0; i < depth; i++) {
3082
- result += '\n}';
3083
- }
3084
- code = result;
3598
+ catch {
3599
+ return false;
3085
3600
  }
3086
- return code;
3087
3601
  }
3088
3602
  /**
3089
3603
  * Extract the first complete function/class from code.
@@ -3191,7 +3705,17 @@ document.addEventListener('DOMContentLoaded', () => {
3191
3705
  }
3192
3706
  }
3193
3707
  async explainCode(code, language) {
3194
- const sysPrompt = `You are a code explainer. Explain the following ${language} code clearly and concisely. Focus on what it does, how it works, and any notable patterns or potential issues.`;
3708
+ const sysPrompt = [
3709
+ `You are a code explainer. Explain the following ${language} code clearly and concisely.`,
3710
+ 'Focus on what it does, how it works, and any notable patterns or potential issues.',
3711
+ 'Format your response as clean Markdown:',
3712
+ '- Use ## headers for major sections (e.g. ## Overview, ## How It Works, ## Key Details).',
3713
+ '- Use bullet points (- or *) for all lists. Do NOT use numbered lists.',
3714
+ '- Wrap code references in backticks.',
3715
+ '- Keep paragraphs short (2-3 sentences max).',
3716
+ '- Do NOT use raw HTML or excessive blank lines.',
3717
+ '- Do NOT nest numbered lists inside sections.',
3718
+ ].join('\n');
3195
3719
  return this.chatComplete(sysPrompt, code);
3196
3720
  }
3197
3721
  async reviewCode(code, language) {
@@ -3203,9 +3727,11 @@ document.addEventListener('DOMContentLoaded', () => {
3203
3727
  'Rules:',
3204
3728
  '- Return concrete, line-specific issues with severity.',
3205
3729
  '- Every issue MUST reference a line number.',
3206
- '- If the score is below 50, list at least 2 specific issues.',
3730
+ '- Report each distinct bug ONCE. Do NOT report the same bug multiple times with different wording.',
3731
+ '- For trivial/short code (< 10 lines), report ONLY actual bugs. Do NOT pad with style, robustness, or best-practice suggestions.',
3732
+ '- If you find a real bug (wrong operator, logic error, type mismatch), report ONLY that bug. Do NOT also suggest input validation, type checking, or error handling unless those are ACTUAL bugs.',
3207
3733
  '- Prioritize REAL BUGS: wrong operators, logic errors, off-by-one, type mismatches.',
3208
- '- Only report style issues AFTER listing all real bugs.',
3734
+ '- Do NOT suggest adding error handling, input validation, or documentation as issues unless the user explicitly asked for a style review.',
3209
3735
  '- Return ONLY the JSON object, no markdown fences or extra text.',
3210
3736
  ].join('\n');
3211
3737
  let raw = {};
@@ -3220,25 +3746,40 @@ document.addEventListener('DOMContentLoaded', () => {
3220
3746
  const score = typeof raw.score === 'number' ? raw.score : 0;
3221
3747
  const issues = Array.isArray(raw.issues) ? raw.issues : [];
3222
3748
  const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
3223
- // Always run client-side heuristics and merge any findings the
3224
- // server missed. This ensures arithmetic/logic bugs are surfaced
3225
- // even when the server only reports style issues like console.log.
3749
+ // Merge client-side heuristics, but with tight dedup to avoid
3750
+ // redundant over-reporting when the model already found the bug.
3751
+ const modelFoundError = issues.some(i => i.severity === 'error');
3226
3752
  const heuristic = this.heuristicCodeIssues(code, language);
3227
3753
  for (const h of heuristic) {
3228
- // Always include critical logic bugs (severity error) from heuristics
3229
- // regardless of server results these catch wrong-operator bugs the
3230
- // server frequently misses.
3231
- if (h.severity === 'error') {
3232
- const exactDuplicate = issues.some((existing) => existing.line === h.line && existing.message === h.message);
3233
- if (!exactDuplicate) {
3234
- issues.push(h);
3235
- }
3754
+ // If the model already found a real error, skip non-error heuristics
3755
+ // entirely they're just padding (style, robustness, etc.)
3756
+ if (modelFoundError && h.severity !== 'error')
3236
3757
  continue;
3237
- }
3238
- // For non-critical heuristics, avoid duplicating issues the server
3239
- // already reported on the same line with the same type.
3240
- const isDuplicate = issues.some((existing) => existing.line === h.line && existing.type === h.type);
3241
- if (!isDuplicate) {
3758
+ // Semantic duplicate check: same line + (similar type OR overlapping
3759
+ // keywords in the message). This catches cases where the model
3760
+ // and heuristic describe the same bug with different wording.
3761
+ const hWords = new Set(h.message.toLowerCase().split(/\W+/).filter(w => w.length > 3));
3762
+ const hTypeNorm = h.type.toLowerCase().replace(/[^a-z]/g, '');
3763
+ const isSemanticallyDuplicate = issues.some((existing) => {
3764
+ if (existing.line !== h.line)
3765
+ return false;
3766
+ // Normalize types: "logic-error", "logic_error", "logic" all match
3767
+ const eTypeNorm = existing.type.toLowerCase().replace(/[^a-z]/g, '');
3768
+ if (eTypeNorm === hTypeNorm || eTypeNorm.startsWith(hTypeNorm) || hTypeNorm.startsWith(eTypeNorm))
3769
+ return true;
3770
+ // Both errors on same line about the same category of problem
3771
+ if (existing.severity === 'error' && h.severity === 'error')
3772
+ return true;
3773
+ // Check keyword overlap — if ≥2 significant words match, it's the same finding
3774
+ const eWords = existing.message.toLowerCase().split(/\W+/).filter(w => w.length > 3);
3775
+ let overlap = 0;
3776
+ for (const w of eWords) {
3777
+ if (hWords.has(w))
3778
+ overlap++;
3779
+ }
3780
+ return overlap >= 2;
3781
+ });
3782
+ if (!isSemanticallyDuplicate) {
3242
3783
  issues.push(h);
3243
3784
  }
3244
3785
  }
@@ -3387,9 +3928,11 @@ document.addEventListener('DOMContentLoaded', () => {
3387
3928
  const sysPrompt = [
3388
3929
  `You are a ${language} code fixer. Fix the code for: ${fixType}.`,
3389
3930
  'Return a JSON object with:',
3390
- ' "fixed": the corrected code as a string,',
3931
+ ' "fixed": the COMPLETE corrected source code as a string (not a snippet — the full file),',
3391
3932
  ' "changes": [{ "line": number, "before": string, "after": string, "reason": string }]',
3392
3933
  'Rules:',
3934
+ '- The "fixed" field MUST contain the entire corrected source code with ALL lines, including unchanged lines.',
3935
+ '- The "fixed" code MUST have balanced braces, parentheses, and brackets.',
3393
3936
  '- Fix ONLY the issues related to the fix type.',
3394
3937
  '- Do not add comments, do not restructure beyond the minimal fix.',
3395
3938
  '- Return ONLY the JSON object, no markdown fences.',
@@ -3429,6 +3972,26 @@ document.addEventListener('DOMContentLoaded', () => {
3429
3972
  if (fixType === 'syntax' && fixed !== code) {
3430
3973
  fixed = this.repairBracketBalance(code, fixed);
3431
3974
  }
3975
+ // Final bracket-balance guarantee — ensure the emitted code has
3976
+ // balanced braces/parens/brackets regardless of what the model returned.
3977
+ fixed = this.ensureBalancedBraces(fixed);
3978
+ // For JS/TS syntax fixes, validate the output actually parses.
3979
+ // If it doesn't, attempt a more aggressive bracket repair.
3980
+ if ((fixType === 'syntax' || fixType === 'bugs') && fixed !== code) {
3981
+ const lang = language.toLowerCase();
3982
+ if (['javascript', 'js', 'typescript', 'ts'].includes(lang)) {
3983
+ if (!this.validateJsSyntax(fixed)) {
3984
+ // Try once more: strip any remaining injected comments and re-balance
3985
+ let repaired = this.stripInjectedComments(code, fixed, language);
3986
+ repaired = this.ensureBalancedBraces(repaired);
3987
+ if (this.validateJsSyntax(repaired)) {
3988
+ fixed = repaired;
3989
+ }
3990
+ // If still invalid, return the best-effort fix — better than
3991
+ // silently reverting to the original broken code.
3992
+ }
3993
+ }
3994
+ }
3432
3995
  // If there are still no changes but the fixed code differs, compute
3433
3996
  // a semantic diff using LCS so inserted/removed lines don't cause
3434
3997
  // every subsequent line to appear as changed.
@@ -3765,7 +4328,7 @@ document.addEventListener('DOMContentLoaded', () => {
3765
4328
  }
3766
4329
  async getCoderHealth() {
3767
4330
  try {
3768
- const response = await this.client.get('/api/health', { timeout: 10000 });
4331
+ const response = await this.client.get('/api/health', { timeout: 5000 });
3769
4332
  const ok = response.data?.status === 'ok' || response.data?.healthy === true;
3770
4333
  return {
3771
4334
  name: 'Coder API',
@@ -3787,8 +4350,8 @@ document.addEventListener('DOMContentLoaded', () => {
3787
4350
  const modelsApiUrl = this.config.get('modelsApiUrl');
3788
4351
  try {
3789
4352
  const [healthResponse, modelsResponse] = await Promise.all([
3790
- this.modelRouterClient.get('/health', { timeout: 10000 }),
3791
- this.modelRouterClient.get('/v1/models', { timeout: 15000 }),
4353
+ this.modelRouterClient.get('/health', { timeout: 5000 }),
4354
+ this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
3792
4355
  ]);
3793
4356
  const healthOk = healthResponse.data?.status === 'healthy'
3794
4357
  || healthResponse.data?.status === 'ok'
@@ -3819,7 +4382,7 @@ document.addEventListener('DOMContentLoaded', () => {
3819
4382
  return null;
3820
4383
  }
3821
4384
  try {
3822
- const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 10000 });
4385
+ const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 5000 });
3823
4386
  const ok = response.data?.status === 'healthy'
3824
4387
  || response.data?.status === 'ok'
3825
4388
  || response.data?.healthy === true;
@@ -3839,29 +4402,6 @@ document.addEventListener('DOMContentLoaded', () => {
3839
4402
  };
3840
4403
  }
3841
4404
  }
3842
- async attemptV3ServiceRecovery(reason = '', options = {}) {
3843
- const attempts = Math.max(1, Number(options.attempts || 2));
3844
- const delayMs = Math.max(0, Number(options.delayMs || 1200));
3845
- let lastError = '';
3846
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
3847
- const health = await this.getV3AgentHealth();
3848
- if (health.ok) {
3849
- const msg = attempt === 1
3850
- ? 'V3 service is reachable.'
3851
- : `V3 service recovered after retry ${attempt}.`;
3852
- return { recovered: true, message: msg, endpoint: health.endpoint };
3853
- }
3854
- lastError = health.error || 'health probe failed';
3855
- if (attempt < attempts && delayMs > 0) {
3856
- await new Promise((resolve) => setTimeout(resolve, delayMs));
3857
- }
3858
- }
3859
- const reasonText = sanitizeUserFacingErrorText(reason || lastError || 'unknown failure');
3860
- return {
3861
- recovered: false,
3862
- message: reasonText ? `Recovery failed: ${reasonText}` : 'Recovery failed: V3 service is still unreachable.',
3863
- };
3864
- }
3865
4405
  async getV3AgentHealth() {
3866
4406
  const baseUrl = this.getV3AgentBaseUrls()[0];
3867
4407
  // Try multiple health endpoint patterns — the V3 backend may expose
@@ -3875,7 +4415,7 @@ document.addEventListener('DOMContentLoaded', () => {
3875
4415
  for (const endpoint of candidates) {
3876
4416
  try {
3877
4417
  const controller = new AbortController();
3878
- const timer = setTimeout(() => controller.abort(), 8000);
4418
+ const timer = setTimeout(() => controller.abort(), 3000);
3879
4419
  const response = await fetch(endpoint, {
3880
4420
  method: 'GET',
3881
4421
  headers,
@@ -3923,7 +4463,7 @@ document.addEventListener('DOMContentLoaded', () => {
3923
4463
  const runUrl = this.getV3AgentRunUrl(baseUrl);
3924
4464
  try {
3925
4465
  const controller = new AbortController();
3926
- const timer = setTimeout(() => controller.abort(), 5000);
4466
+ const timer = setTimeout(() => controller.abort(), 2000);
3927
4467
  const probe = await fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
3928
4468
  clearTimeout(timer);
3929
4469
  if (probe.ok || probe.status === 204 || probe.status === 405) {
@@ -4046,6 +4586,20 @@ document.addEventListener('DOMContentLoaded', () => {
4046
4586
  };
4047
4587
  }
4048
4588
  }
4589
+ async runSelfHealingCycle(_originalPrompt, _workspacePath, _context = {}) {
4590
+ return {
4591
+ healingAttempted: false,
4592
+ passed: true,
4593
+ tool: 'disabled',
4594
+ };
4595
+ }
4596
+ async attemptV3ServiceRecovery(reason = '', _options = {}) {
4597
+ const safeReason = sanitizeUserFacingErrorText(reason || 'unknown failure');
4598
+ return {
4599
+ recovered: false,
4600
+ message: safeReason ? `Recovery unavailable: ${safeReason}` : 'Recovery unavailable',
4601
+ };
4602
+ }
4049
4603
  async getDevtoolsBridgeStatus() {
4050
4604
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
4051
4605
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
@@ -4080,11 +4634,18 @@ document.addEventListener('DOMContentLoaded', () => {
4080
4634
  });
4081
4635
  }
4082
4636
  async getCapabilityTruthStatus(context = {}) {
4637
+ // Wrap each probe with its own 6 s timeout so they always resolve
4638
+ // before the outer 8 s race in auth.ts, producing real error messages
4639
+ // (ECONNREFUSED, 404, etc.) instead of the generic "Timed out (8s)".
4640
+ const withTimeout = (p, name) => Promise.race([
4641
+ p,
4642
+ new Promise(resolve => setTimeout(() => resolve({ name, endpoint: '', ok: false, error: 'Service not reachable (6 s timeout)' }), 6000)),
4643
+ ]);
4083
4644
  const [v3Agent, hyperLoop, repoMemory, devtoolsBridge] = await Promise.all([
4084
- this.getV3AgentHealth(),
4085
- this.getHyperLoopHealth(),
4086
- this.getRepoMemoryHealth(context),
4087
- this.getDevtoolsBridgeStatus(),
4645
+ withTimeout(this.getV3AgentHealth(), 'V3 Agent'),
4646
+ withTimeout(this.getHyperLoopHealth(), 'Hyper Loop'),
4647
+ withTimeout(this.getRepoMemoryHealth(context), 'Repo Memory'),
4648
+ withTimeout(this.getDevtoolsBridgeStatus(), 'DevTools Bridge'),
4088
4649
  ]);
4089
4650
  return {
4090
4651
  overallOk: v3Agent.ok && hyperLoop.ok && repoMemory.ok,