vigthoria-cli 1.9.2 → 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
@@ -8,18 +8,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.APIClient = exports.CLIError = void 0;
11
- exports.handleApiError = handleApiError;
12
- exports.handleAuthError = handleAuthError;
13
- exports.propagateError = propagateError;
14
11
  exports.classifyError = classifyError;
15
12
  exports.formatCLIError = formatCLIError;
16
13
  exports.sanitizeUserFacingErrorText = sanitizeUserFacingErrorText;
17
14
  exports.isServerRuntime = isServerRuntime;
18
15
  exports.describeUpstreamStatus = describeUpstreamStatus;
19
- exports.validateJwtExpiry = validateJwtExpiry;
20
- exports.validateJwt = validateJwt;
21
- exports.refreshJwtIfNeeded = refreshJwtIfNeeded;
22
- exports.createApiClient = createApiClient;
16
+ exports.propagateError = propagateError;
23
17
  const axios_1 = __importDefault(require("axios"));
24
18
  const crypto_1 = require("crypto");
25
19
  const fs_1 = __importDefault(require("fs"));
@@ -27,202 +21,25 @@ const https_1 = __importDefault(require("https"));
27
21
  const net_1 = __importDefault(require("net"));
28
22
  const path_1 = __importDefault(require("path"));
29
23
  const ws_1 = __importDefault(require("ws"));
30
- const logger_js_1 = require("./logger.js");
31
- const context_ranker_js_1 = require("./context-ranker.js");
32
- const post_write_validator_js_1 = require("./post-write-validator.js");
33
- const workspace_cache_js_1 = require("./workspace-cache.js");
34
24
  class CLIError extends Error {
35
25
  category;
36
26
  statusCode;
37
27
  endpoint;
38
- code;
39
- details;
40
- isCritical;
41
28
  constructor(message, category, opts) {
42
29
  super(message);
43
30
  this.name = 'CLIError';
44
31
  this.category = category;
45
32
  this.statusCode = opts?.statusCode;
46
33
  this.endpoint = opts?.endpoint;
47
- this.code = category === 'auth' || category === 'repo_session' ? 'AUTH_REQUIRED' : category.toUpperCase();
48
- this.details = { status: opts?.statusCode, endpoint: opts?.endpoint };
49
- this.isCritical = category === 'auth' || category === 'repo_session' || category === 'model_backend' || Boolean(opts?.statusCode && opts.statusCode >= 500);
50
34
  if (opts?.cause)
51
35
  this.cause = opts.cause;
52
36
  }
53
37
  }
54
38
  exports.CLIError = CLIError;
55
- function toCliError(error, fallbackCode = 'API_ERROR', fallbackMessage = 'API request failed') {
56
- if (isCliError(error))
57
- return error;
58
- const axErr = error;
59
- const status = axErr?.response?.status;
60
- const fetchStatus = typeof error?.status === 'number' ? error.status : undefined;
61
- const effectiveStatus = status ?? fetchStatus;
62
- const data = axErr?.response?.data;
63
- const message = typeof data?.error === 'string'
64
- ? data.error
65
- : typeof data?.message === 'string'
66
- ? data.message
67
- : error instanceof Error && error.message
68
- ? error.message
69
- : fallbackMessage;
70
- const code = effectiveStatus === 401 || effectiveStatus === 403
71
- ? 'AUTH_REQUIRED'
72
- : effectiveStatus && effectiveStatus >= 500
73
- ? 'SERVER_ERROR'
74
- : /timeout|ETIMEDOUT|ESOCKETTIMEDOUT|aborted/i.test(message)
75
- ? 'TIMEOUT'
76
- : /ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|fetch failed/i.test(message)
77
- ? 'NETWORK_ERROR'
78
- : fallbackCode;
79
- return {
80
- code,
81
- message,
82
- details: {
83
- status: effectiveStatus,
84
- endpoint: axErr?.config?.url || axErr?.config?.baseURL,
85
- data,
86
- },
87
- isCritical: code === 'AUTH_REQUIRED' || code === 'AUTH_REFRESH_FAILED' || code === 'SERVER_ERROR' || fallbackCode === 'API_ERROR',
88
- };
89
- }
90
- function isCliError(error) {
91
- return Boolean(error && typeof error === 'object' && 'code' in error && 'message' in error && 'isCritical' in error);
92
- }
93
- function isRetryableApiError(error) {
94
- const status = error instanceof CLIError
95
- ? error.statusCode
96
- : typeof error.details?.status === 'number'
97
- ? error.details.status
98
- : undefined;
99
- const code = String(error.code || '').toUpperCase();
100
- const message = String(error.message || '');
101
- if (status === 401 || status === 403)
102
- return false;
103
- if (status !== undefined && status >= 500)
104
- return true;
105
- return code === 'TIMEOUT'
106
- || code === 'NETWORK_ERROR'
107
- || /timeout|timed out|ECONNRESET|ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN/i.test(message);
108
- }
109
- function decodeJwtPayload(token) {
110
- try {
111
- const parts = token.split('.');
112
- if (parts.length !== 3 || !parts[1])
113
- return null;
114
- const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
115
- const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
116
- const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
117
- return decoded && typeof decoded === 'object' ? decoded : null;
118
- }
119
- catch (error) {
120
- return null;
121
- }
122
- }
123
- function getJwtExpiresAt(token) {
124
- if (!token)
125
- return null;
126
- const payload = decodeJwtPayload(token);
127
- const exp = payload?.exp;
128
- return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null;
129
- }
130
- const JWT_EXPIRY_SKEW_MS = 30_000;
131
- function isJwtUsable(token) {
132
- const expiresAt = getJwtExpiresAt(token);
133
- return Boolean(token && expiresAt !== null && Date.now() + JWT_EXPIRY_SKEW_MS < expiresAt);
134
- }
135
- function isJwtExpired(token) {
136
- const expiresAt = getJwtExpiresAt(token);
137
- return Boolean(token && expiresAt !== null && Date.now() + JWT_EXPIRY_SKEW_MS >= expiresAt);
138
- }
139
- function shouldAttemptJwtRefresh(token) {
140
- return Boolean(token && (!isJwtUsable(token) || isJwtExpired(token)));
141
- }
142
- function wrapApiError(error, fallbackCode = 'API_ERROR', fallbackMessage = 'API request failed') {
143
- const cliError = toCliError(error, fallbackCode, fallbackMessage);
144
- return { ...cliError, details: { ...(cliError.details || {}), retryable: isRetryableApiError(cliError) } };
145
- }
146
- function handleApiError(error) {
147
- return wrapApiError(error, 'API_ERROR', 'API request failed');
148
- }
149
- function handleAuthError(error) {
150
- const cliError = toCliError(error, 'AUTH_ERROR', 'Authentication failed. Please run: vigthoria login');
151
- if (cliError.code === 'AUTH_REQUIRED' || cliError.code === 'AUTH_ERROR') {
152
- return { ...cliError, code: 'AUTH_REQUIRED', message: cliError.message || 'Authentication failed. Please run: vigthoria login', isCritical: true };
153
- }
154
- return cliError;
155
- }
156
- function propagateError(err) {
157
- const responseData = err?.response?.data;
158
- const existingDetails = err?.details && typeof err.details === 'object' ? err.details : {};
159
- const status = typeof err?.response?.status === 'number'
160
- ? err.response.status
161
- : typeof err?.status === 'number'
162
- ? err.status
163
- : typeof err?.statusCode === 'number'
164
- ? err.statusCode
165
- : typeof existingDetails.status === 'number'
166
- ? existingDetails.status
167
- : typeof err?.code === 'number'
168
- ? err.code
169
- : 500;
170
- const endpoint = err?.endpoint || err?.config?.url || err?.config?.baseURL || existingDetails.endpoint || 'unknown';
171
- const command = err?.commandName || err?.command || existingDetails.command || 'unknown';
172
- const message = typeof responseData?.error === 'string'
173
- ? responseData.error
174
- : typeof responseData?.message === 'string'
175
- ? responseData.message
176
- : typeof err?.message === 'string' && err.message
177
- ? err.message
178
- : 'API request failed';
179
- const originalCode = err?.code;
180
- const isAuthError = status === 401
181
- || status === 403
182
- || err?.isAuthError === true
183
- || String(originalCode || '').toUpperCase() === 'AUTH_REQUIRED';
184
- const apiError = {
185
- code: status,
186
- message,
187
- details: {
188
- ...existingDetails,
189
- command,
190
- endpoint,
191
- status,
192
- data: responseData ?? existingDetails.data,
193
- originalCode,
194
- originalName: err?.name,
195
- },
196
- isAuthError,
197
- };
198
- const logPayload = {
199
- code: apiError.code,
200
- message: apiError.message,
201
- command,
202
- endpoint,
203
- status,
204
- isAuthError: apiError.isAuthError,
205
- };
206
- try {
207
- console.error('[Vigthoria API Error]', JSON.stringify(logPayload));
208
- }
209
- catch {
210
- console.error('[Vigthoria API Error]', logPayload);
211
- }
212
- throw apiError;
213
- }
214
39
  /** Classify an axios or fetch error into a structured CLIError. */
215
40
  function classifyError(error, fallbackCategory = 'network') {
216
41
  if (error instanceof CLIError)
217
42
  return error;
218
- const structuredApiError = error;
219
- if (structuredApiError && typeof structuredApiError.code === 'number' && typeof structuredApiError.message === 'string') {
220
- return new CLIError(structuredApiError.message, structuredApiError.isAuthError ? 'auth' : structuredApiError.code >= 500 ? 'model_backend' : fallbackCategory, {
221
- statusCode: structuredApiError.code,
222
- endpoint: typeof structuredApiError.details?.endpoint === 'string' ? structuredApiError.details.endpoint : undefined,
223
- cause: error instanceof Error ? error : undefined,
224
- });
225
- }
226
43
  const axErr = error;
227
44
  const status = axErr?.response?.status;
228
45
  const endpoint = axErr?.config?.url || axErr?.config?.baseURL || '';
@@ -275,150 +92,56 @@ function formatCLIError(err) {
275
92
  return `${tag} ${err.message}`;
276
93
  }
277
94
  }
278
- // Sanitize an upstream error string before exposing it to the end user.
279
95
  function sanitizeUserFacingErrorText(input) {
280
- if (!input)
96
+ const raw = String(input || '').trim();
97
+ if (!raw) {
281
98
  return '';
282
- let out = String(input);
283
- out = out.replace(/https?:\/\/[^\s'"<>)]+/gi, '[redacted-url]');
284
- out = out.replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, '[redacted-host]');
285
- out = out.replace(/\b(?:localhost|127\.0\.0\.1)(?::\d+)?\b/gi, '[redacted-host]');
286
- out = out.replace(/\b[a-z0-9.-]+\.vigthoria\.io\b/gi, '[redacted-host]');
287
- out = out.replace(/(?:[A-Za-z]:)?[\\/](?:var|opt|tmp|home|root|etc|usr)[\\/][^\s'"<>)]*/gi, '[redacted-path]');
288
- out = out.replace(/[A-Za-z]:\\[^\s'"<>)]+/g, '[redacted-path]');
289
- out = out.replace(/\\\\[^\s'"<>)]+/g, '[redacted-path]');
290
- out = out.replace(/\s+/g, ' ').trim();
291
- if (out.length > 160)
292
- out = out.slice(0, 160) + '...';
293
- return out;
99
+ }
100
+ const withoutTags = raw.replace(/<[^>]+>/g, ' ');
101
+ return withoutTags.replace(/\s+/g, ' ').trim();
294
102
  }
295
103
  function isServerRuntime() {
296
- if (process.env.VIGTHORIA_RUN_MODE === 'server')
297
- return true;
298
- if (process.env.VIGTHORIA_SERVER_RUNTIME === '1')
104
+ if (process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1') {
299
105
  return true;
300
- 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');
301
110
  }
302
111
  function describeUpstreamStatus(status) {
303
- if (status === 401 || status === 403)
304
- return 'Authentication failed. Please run vigthoria login.';
305
- if (status === 404)
306
- return 'Requested service endpoint was not found.';
307
- if (status === 408 || status === 504)
308
- return 'Upstream service timed out.';
309
- if (status === 429)
310
- return 'Rate limit reached. Please retry shortly.';
311
112
  if (status >= 500)
312
- 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';
313
122
  if (status >= 400)
314
- return 'Request was rejected by the service.';
315
- return 'Unexpected response from service.';
316
- }
317
- const JWT_VALIDATE_EXPIRY_SKEW_MS = 60_000;
318
- function validateJwtExpiry(token) {
319
- if (!token || typeof token !== 'string')
320
- return false;
321
- const payload = decodeJwtPayload(token);
322
- const exp = payload?.exp;
323
- if (typeof exp !== 'number' || !Number.isFinite(exp))
324
- return false;
325
- return Date.now() + JWT_VALIDATE_EXPIRY_SKEW_MS < exp * 1000;
326
- }
327
- function validateJwt(token) {
328
- if (!token || typeof token !== 'string')
329
- return null;
330
- const payload = decodeJwtPayload(token);
331
- if (!payload)
332
- return null;
333
- try {
334
- if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp))
335
- return null;
336
- if (!validateJwtExpiry(token))
337
- return null;
338
- return payload;
339
- }
340
- catch (error) {
341
- if (process.env.VIGTHORIA_DEBUG_JWT === '1') {
342
- // Avoid logging token contents; only expose parser failure class for diagnostics.
343
- console.debug('Failed to validate JWT payload:', error instanceof Error ? error.message : String(error));
344
- }
345
- return null;
346
- }
123
+ return 'bad request';
124
+ return 'ok';
347
125
  }
348
- async function refreshJwtIfNeeded(state) {
349
- const runtimeClient = state;
350
- try {
351
- const currentToken = runtimeClient.getAccessToken?.() || runtimeClient.config?.get('authToken') || state.token;
352
- if (!currentToken) {
353
- state.token = null;
354
- state.expiresAt = null;
355
- return null;
356
- }
357
- const decodedExpiresAt = getJwtExpiresAt(currentToken);
358
- state.token = currentToken;
359
- state.expiresAt = decodedExpiresAt;
360
- const expired = (typeof state.isExpired === 'function' ? state.isExpired() : shouldAttemptJwtRefresh(currentToken)) || decodedExpiresAt === null;
361
- if (!expired) {
362
- return currentToken;
363
- }
364
- const refreshToken = runtimeClient.getRefreshToken?.() || runtimeClient.config?.get('refreshToken');
365
- if (refreshToken && runtimeClient.refreshToken) {
366
- const refreshed = await runtimeClient.refreshToken().catch((error) => {
367
- runtimeClient.logger?.debug?.(`JWT refresh request failed: ${error instanceof Error ? error.message : String(error)}`);
368
- throw error;
369
- });
370
- const nextToken = runtimeClient.getAccessToken?.() || runtimeClient.config?.get('authToken') || null;
371
- if (refreshed && nextToken && validateJwtExpiry(nextToken)) {
372
- state.token = nextToken;
373
- state.expiresAt = getJwtExpiresAt(nextToken);
374
- return nextToken;
375
- }
376
- runtimeClient.logger?.debug?.('JWT refresh endpoint did not return a usable replacement token');
377
- }
378
- state.token = null;
379
- state.expiresAt = null;
380
- runtimeClient.config?.clearAuth();
381
- throw new CLIError('Authentication token is expired or invalid. Please run: vigthoria login', 'auth');
382
- }
383
- catch (error) {
384
- if (error instanceof CLIError) {
385
- throw error;
386
- }
387
- const cliError = toCliError(error, 'AUTH_REFRESH_FAILED', 'Authentication refresh failed. Please run: vigthoria login');
388
- state.token = null;
389
- state.expiresAt = null;
390
- runtimeClient.config?.clearAuth();
391
- throw new CLIError(cliError.message, 'auth', {
392
- statusCode: typeof cliError.details?.status === 'number' ? cliError.details.status : undefined,
393
- endpoint: typeof cliError.details?.endpoint === 'string' ? cliError.details.endpoint : undefined,
394
- cause: error instanceof Error ? error : undefined,
395
- });
396
- }
397
- }
398
- function createApiClient(config) {
399
- const apiClient = new APIClient(config, new logger_js_1.Logger());
400
- return {
401
- get: async (path) => {
402
- try {
403
- const response = await apiClient.client.get(path);
404
- return response.data;
405
- }
406
- catch (err) {
407
- propagateError(err);
408
- }
409
- },
410
- post: async (path, body) => {
411
- try {
412
- const response = await apiClient.client.post(path, body);
413
- return response.data;
414
- }
415
- catch (err) {
416
- propagateError(err);
417
- }
418
- },
419
- handleAuthError: (err) => {
420
- const authError = handleAuthError(err);
421
- throw new CLIError(authError.message, 'auth', { cause: err instanceof Error ? err : undefined });
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,
422
145
  },
423
146
  };
424
147
  }
@@ -432,11 +155,6 @@ const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
432
155
  const parsed = Number.parseInt(rawValue, 10);
433
156
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
434
157
  })();
435
- const DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS = (() => {
436
- const rawValue = process.env.VIGTHORIA_AGENT_SOFT_TIMEOUT_MS || process.env.V3_AGENT_SOFT_TIMEOUT_MS || '300000';
437
- const parsed = Number.parseInt(rawValue, 10);
438
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
439
- })();
440
158
  const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
441
159
  const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS || '300000';
442
160
  const parsed = Number.parseInt(rawValue, 10);
@@ -447,11 +165,10 @@ class APIClient {
447
165
  modelRouterClient;
448
166
  selfHostedModelRouterClient;
449
167
  config;
450
- token = null;
451
- expiresAt = null;
452
168
  logger;
453
169
  ws = null;
454
170
  vigFlowTokens = new Map();
171
+ _httpsAgent = null;
455
172
  constructor(config, logger) {
456
173
  this.config = config;
457
174
  this.logger = logger;
@@ -461,6 +178,7 @@ class APIClient {
461
178
  keepAlive: true,
462
179
  timeout: 30000,
463
180
  });
181
+ this._httpsAgent = httpsAgent;
464
182
  // Main Vigthoria Coder API (coder.vigthoria.io)
465
183
  this.client = axios_1.default.create({
466
184
  baseURL: config.get('apiUrl'),
@@ -492,77 +210,41 @@ class APIClient {
492
210
  'User-Agent': `Vigthoria-CLI/${process.env.npm_package_version || '1.6.9'}`,
493
211
  },
494
212
  }) : null;
495
- // Add auth interceptors that validate JWT expiry before every request.
496
- const createAuthRequestInterceptor = (client, includeCookie) => {
497
- client.interceptors.request.use(async (req) => {
498
- const skipAuthRefresh = Boolean(req.__skipAuthRefresh) || req.url?.includes('/api/token/refresh') || req.url?.includes('/api/login');
499
- if (!skipAuthRefresh) {
500
- await refreshJwtIfNeeded(this);
501
- }
502
- const token = this.getAccessToken();
503
- if (token) {
504
- const payload = validateJwt(token);
505
- if (!payload && !skipAuthRefresh) {
506
- throw new CLIError('Authentication token is expired or invalid. Please run: vigthoria login', 'auth', { endpoint: req.url });
507
- }
508
- req.headers.Authorization = `Bearer ${token}`;
509
- if (includeCookie) {
510
- req.headers.Cookie = `vigthoria-auth-token=${token}`;
511
- }
512
- }
513
- return req;
514
- });
515
- };
516
- createAuthRequestInterceptor(this.client, true);
517
- createAuthRequestInterceptor(this.modelRouterClient, true);
518
- if (this.selfHostedModelRouterClient) {
519
- createAuthRequestInterceptor(this.selfHostedModelRouterClient, false);
520
- }
521
- // Add response interceptors for token refresh + structured errors.
213
+ // Add auth interceptor
214
+ this.client.interceptors.request.use((req) => {
215
+ const token = this.config.get('authToken');
216
+ if (token) {
217
+ req.headers.Authorization = `Bearer ${token}`;
218
+ req.headers.Cookie = `vigthoria-auth-token=${token}`;
219
+ }
220
+ return req;
221
+ });
222
+ this.modelRouterClient.interceptors.request.use((req) => {
223
+ const token = this.config.get('authToken');
224
+ if (token) {
225
+ req.headers.Authorization = `Bearer ${token}`;
226
+ req.headers.Cookie = `vigthoria-auth-token=${token}`;
227
+ }
228
+ return req;
229
+ });
230
+ this.selfHostedModelRouterClient?.interceptors.request.use((req) => {
231
+ const token = this.config.get('authToken');
232
+ if (token) {
233
+ req.headers.Authorization = `Bearer ${token}`;
234
+ }
235
+ return req;
236
+ });
237
+ // Add response interceptors for token refresh + structured errors
522
238
  const createAuthRetryInterceptor = (client) => {
523
239
  client.interceptors.response.use((res) => res, async (error) => {
524
- const originalConfig = error.config;
525
- try {
526
- if (error.response?.status === 401) {
527
- if (originalConfig?.__authRetry) {
528
- throw new CLIError('Authentication failed after token refresh. Please run: vigthoria login', 'auth', {
529
- statusCode: 401,
530
- endpoint: originalConfig.url,
531
- cause: error instanceof Error ? error : undefined,
532
- });
533
- }
534
- const refreshedToken = await refreshJwtIfNeeded(this);
535
- if (refreshedToken && originalConfig) {
536
- originalConfig.__authRetry = true;
537
- const token = refreshedToken;
538
- if (token) {
539
- originalConfig.headers = originalConfig.headers || {};
540
- originalConfig.headers.Authorization = `Bearer ${token}`;
541
- originalConfig.headers.Cookie = `vigthoria-auth-token=${token}`;
542
- }
543
- return client.request(originalConfig);
544
- }
545
- throw new CLIError('Authentication token expired or was rejected. Please run: vigthoria login', 'auth', {
546
- statusCode: 401,
547
- endpoint: originalConfig?.url,
548
- cause: error instanceof Error ? error : undefined,
549
- });
240
+ if (error.response?.status === 401) {
241
+ const refreshed = await this.refreshToken();
242
+ if (refreshed && error.config) {
243
+ return client.request(error.config);
550
244
  }
551
- throw classifyError(error);
552
- }
553
- catch (interceptorError) {
554
- if (interceptorError instanceof CLIError) {
555
- throw interceptorError;
556
- }
557
- const authError = error.response?.status === 401
558
- ? handleAuthError(interceptorError)
559
- : toCliError(interceptorError, 'API_ERROR', 'API request failed');
560
- throw new CLIError(authError.message, authError.code === 'AUTH_REQUIRED' ? 'auth' : 'network', {
561
- statusCode: error.response?.status,
562
- endpoint: originalConfig?.url,
563
- cause: interceptorError instanceof Error ? interceptorError : error,
564
- });
245
+ throw classifyError(error, 'auth');
565
246
  }
247
+ throw classifyError(error);
566
248
  });
567
249
  };
568
250
  createAuthRetryInterceptor(this.client);
@@ -571,12 +253,22 @@ class APIClient {
571
253
  createAuthRetryInterceptor(this.selfHostedModelRouterClient);
572
254
  }
573
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
+ */
574
262
  destroy() {
263
+ if (this._httpsAgent) {
264
+ this._httpsAgent.destroy();
265
+ this._httpsAgent = null;
266
+ }
575
267
  if (this.ws) {
576
268
  try {
577
269
  this.ws.close();
578
270
  }
579
- catch { }
271
+ catch { /* ok */ }
580
272
  this.ws = null;
581
273
  }
582
274
  }
@@ -656,26 +348,6 @@ class APIClient {
656
348
  }
657
349
  }
658
350
  }
659
- // All profile endpoints failed — fall back to JWT payload claims so the
660
- // token is still usable without identity fields being null.
661
- const jwtPayload = this.decodeJwtPayload(token);
662
- const fallbackId = String(jwtPayload?.sub || jwtPayload?.user_id || jwtPayload?.id || '').trim();
663
- const fallbackEmail = String(jwtPayload?.email || '').trim();
664
- const fallbackPlan = String(jwtPayload?.plan || jwtPayload?.subscription_plan || 'developer').trim();
665
- if (fallbackId || fallbackEmail) {
666
- this.config.setAuth({
667
- token,
668
- userId: fallbackId || fallbackEmail,
669
- email: fallbackEmail || fallbackId,
670
- });
671
- this.config.setSubscription({
672
- plan: fallbackPlan,
673
- status: 'active',
674
- expiresAt: undefined,
675
- });
676
- return true;
677
- }
678
- this.logger.warn('Token validation failed: no profile endpoint or JWT identity claim matched');
679
351
  this.config.clearAuth();
680
352
  return false;
681
353
  }
@@ -685,19 +357,6 @@ class APIClient {
685
357
  return false;
686
358
  }
687
359
  }
688
- decodeJwtPayload(token) {
689
- try {
690
- const parts = token.split('.');
691
- if (parts.length !== 3) {
692
- return null;
693
- }
694
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
695
- return JSON.parse(payload);
696
- }
697
- catch {
698
- return null;
699
- }
700
- }
701
360
  extractUserProfile(data) {
702
361
  if (!data) {
703
362
  return null;
@@ -734,11 +393,9 @@ class APIClient {
734
393
  }
735
394
  return true;
736
395
  }
737
- catch (error) {
738
- const cliError = toCliError(error, 'AUTH_REFRESH_FAILED', 'Failed to refresh authentication token');
739
- this.logger.debug(`Token refresh failed: ${cliError.message}`);
396
+ catch {
740
397
  this.config.clearAuth();
741
- throw cliError;
398
+ return false;
742
399
  }
743
400
  }
744
401
  async getSubscriptionStatus() {
@@ -773,35 +430,35 @@ class APIClient {
773
430
  if (!token) {
774
431
  return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
775
432
  }
776
- // Validate against authenticated Coder endpoints so we verify
777
- // the actual Vigthoria Gateway user session state.
778
- const authEndpoints = ['/api/user/profile', '/api/user/subscription'];
779
- for (const endpoint of authEndpoints) {
780
- try {
781
- await this.client.get(endpoint, { 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')
782
442
  return { valid: true };
783
- }
784
- catch (error) {
785
- 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) {
786
449
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
787
450
  }
788
- const axErr = error;
789
- if (axErr.response?.status === 401 || axErr.response?.status === 403) {
451
+ if (err instanceof CLIError && err.category === 'auth') {
790
452
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
791
453
  }
792
- // Try the next authenticated endpoint before deciding this is
793
- // a transient network/backend issue.
794
- continue;
795
454
  }
796
455
  }
797
- // Auth endpoints unreachable — do not misclassify as invalid token.
456
+ // Both unreachable — don't assume token is bad
798
457
  return { valid: true };
799
458
  }
800
459
  getV3AgentBaseUrls(preferLocal = false) {
801
460
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
802
- const allowLocalV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
803
- || this.allowLocalServiceFallbacks()
804
- || preferLocal;
461
+ const allowLocalV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1' || preferLocal;
805
462
  const urls = [
806
463
  process.env.VIGTHORIA_V3_AGENT_URL,
807
464
  process.env.V3_AGENT_URL,
@@ -827,7 +484,7 @@ class APIClient {
827
484
  const urls = [
828
485
  process.env.VIGTHORIA_OPERATOR_URL,
829
486
  process.env.OPERATOR_URL,
830
- ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4009'] : []),
487
+ 'http://127.0.0.1:4009',
831
488
  configuredModelsApiUrl,
832
489
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
833
490
  return [...new Set(urls)];
@@ -840,7 +497,7 @@ class APIClient {
840
497
  const urls = [
841
498
  process.env.VIGTHORIA_MCP_URL,
842
499
  process.env.MCP_SERVER_URL,
843
- ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4008'] : []),
500
+ 'http://127.0.0.1:4008',
844
501
  configuredApiUrl,
845
502
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
846
503
  return [...new Set(urls)];
@@ -858,7 +515,8 @@ class APIClient {
858
515
  process.env.VIGFLOW_URL,
859
516
  process.env.WORKFLOW_BUILDER_URL,
860
517
  `${configuredApiUrl}/api/vigflow`,
861
- ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:5060', 'http://127.0.0.1:5050'] : []),
518
+ 'http://127.0.0.1:5060',
519
+ 'http://127.0.0.1:5050',
862
520
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
863
521
  return [...new Set(urls)];
864
522
  }
@@ -867,14 +525,11 @@ class APIClient {
867
525
  const urls = [
868
526
  process.env.VIGTHORIA_TEMPLATE_SERVICE_URL,
869
527
  process.env.TEMPLATE_SERVICE_URL,
870
- ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4011'] : []),
528
+ 'http://127.0.0.1:4011',
871
529
  `${configuredApiUrl}/api/template-service`,
872
530
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
873
531
  return [...new Set(urls)];
874
532
  }
875
- allowLocalServiceFallbacks() {
876
- return process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1' || isServerRuntime();
877
- }
878
533
  isFrontendTask(message = '', context = {}) {
879
534
  // Never treat analysis-only tasks as frontend tasks — preview gate
880
535
  // should not fire for read-only inspection prompts.
@@ -1196,7 +851,7 @@ class APIClient {
1196
851
  });
1197
852
  if (!response.ok) {
1198
853
  const errorText = await response.text().catch(() => '');
1199
- throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
854
+ throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1200
855
  }
1201
856
  const payload = await response.json();
1202
857
  const modes = payload?.modes || {};
@@ -1249,12 +904,6 @@ class APIClient {
1249
904
  'Content-Type': 'application/json',
1250
905
  Accept: 'application/json',
1251
906
  };
1252
- try {
1253
- await refreshJwtIfNeeded(this);
1254
- }
1255
- catch (error) {
1256
- throw toCliError(error, 'AUTH_REFRESH_FAILED', 'Failed to refresh authentication token before API request');
1257
- }
1258
907
  const authToken = this.getAccessToken();
1259
908
  if (authToken) {
1260
909
  headers.Authorization = `Bearer ${authToken}`;
@@ -1266,12 +915,6 @@ class APIClient {
1266
915
  const headers = {
1267
916
  'Content-Type': 'application/json',
1268
917
  };
1269
- try {
1270
- await refreshJwtIfNeeded(this);
1271
- }
1272
- catch (error) {
1273
- throw toCliError(error, 'AUTH_REFRESH_FAILED', 'Failed to refresh authentication token before API request');
1274
- }
1275
918
  const authToken = this.getAccessToken();
1276
919
  if (authToken) {
1277
920
  headers.Authorization = `Bearer ${authToken}`;
@@ -1352,7 +995,8 @@ class APIClient {
1352
995
  this.logger.debug(`VigFlow ${operation} via ${baseUrl} failed:`, lastError.message);
1353
996
  }
1354
997
  }
1355
- 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.`);
1356
1000
  }
1357
1001
  /**
1358
1002
  * Build the correct sub-path for VigFlow endpoints.
@@ -1489,9 +1133,7 @@ class APIClient {
1489
1133
  const targetPath = resolvedContext.targetPath || resolvedContext.projectPath || resolvedContext.workspacePath || resolvedContext.projectRoot || process.cwd();
1490
1134
  const localWorkspacePath = this.resolveAgentTargetPath(resolvedContext);
1491
1135
  const serverWorkspacePath = this.resolveServerBindableWorkspacePath(resolvedContext);
1492
- const localWorkspaceSummary = resolvedContext.rawPrompt
1493
- ? this.buildSemanticWorkspaceSummary(localWorkspacePath, String(resolvedContext.rawPrompt))
1494
- : this.buildLocalWorkspaceSummary(localWorkspacePath);
1136
+ const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath);
1495
1137
  const requestedModel = String(resolvedContext.model || resolvedContext.requestedModel || 'agent');
1496
1138
  const resolvedModel = this.resolvePermittedModelId(requestedModel);
1497
1139
  // When the server cannot directly access the workspace (e.g. Windows
@@ -1646,6 +1288,458 @@ class APIClient {
1646
1288
  const match = String(message || '').match(/called\s+([A-Z][A-Za-z0-9&\- ]{2,40})/i);
1647
1289
  return match?.[1]?.trim() || fallback;
1648
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
+ }
1649
1743
  ensureExecutionContext(context = {}) {
1650
1744
  const existingId = String(context.contextId || context.traceId || '').trim();
1651
1745
  const contextId = existingId || `vig-${Date.now()}-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
@@ -1698,7 +1792,7 @@ class APIClient {
1698
1792
  });
1699
1793
  if (!response.ok) {
1700
1794
  const errorText = await response.text().catch(() => '');
1701
- throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1795
+ throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1702
1796
  }
1703
1797
  return {
1704
1798
  ...executionContext,
@@ -1729,7 +1823,7 @@ class APIClient {
1729
1823
  });
1730
1824
  if (!createResponse.ok) {
1731
1825
  const errorText = await createResponse.text().catch(() => '');
1732
- throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1826
+ throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1733
1827
  }
1734
1828
  const payload = await createResponse.json();
1735
1829
  const mcpContextId = String(payload.contextId || '').trim();
@@ -1819,14 +1913,6 @@ class APIClient {
1819
1913
  * Budget: up to ~2 MB total, per-file cap 200 KB, skip binary extensions.
1820
1914
  */
1821
1915
  collectWorkspaceFileContents(rootPath, filePaths) {
1822
- // Prioritise files that changed since last agent run — budget goes to them first.
1823
- if (rootPath && filePaths.length > 0) {
1824
- try {
1825
- const { changed, unchanged } = (0, workspace_cache_js_1.getChangedFiles)(rootPath, filePaths);
1826
- filePaths = [...changed, ...unchanged];
1827
- }
1828
- catch { /* non-fatal */ }
1829
- }
1830
1916
  const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
1831
1917
  const MAX_FILE_BYTES = 200 * 1024;
1832
1918
  const BINARY_EXTENSIONS = new Set([
@@ -2692,7 +2778,6 @@ document.addEventListener('DOMContentLoaded', () => {
2692
2778
  const rescueEligibleSaaS = preferLocalV3
2693
2779
  && /(saas|dashboard|analytics|billing|team management|activity feed|login screen)/i.test(message);
2694
2780
  const timeoutMs = rescueEligibleSaaS ? Math.min(baseTimeoutMs, 210000) : baseTimeoutMs;
2695
- const softTimeoutMs = executionContext.agentSoftTimeoutMs || DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS;
2696
2781
  const maxAttempts = preferLocalV3 ? 2 : 1;
2697
2782
  let lastErrors = [];
2698
2783
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -2727,13 +2812,11 @@ document.addEventListener('DOMContentLoaded', () => {
2727
2812
  for (const baseUrl of this.getV3AgentBaseUrls(preferLocalV3)) {
2728
2813
  const controller = new AbortController();
2729
2814
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2730
- const softTimeoutId = softTimeoutMs > 0 ? setTimeout(() => controller.abort(), softTimeoutMs) : null;
2731
2815
  try {
2732
2816
  const response = await this.executeV3AgentRunRequest(baseUrl, requestBody, requestExecutionContext, controller.signal);
2733
- clearTimeout(timeoutId);
2734
2817
  if (!response.ok) {
2735
2818
  const errorText = await response.text().catch(() => '');
2736
- throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
2819
+ throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2737
2820
  }
2738
2821
  const data = await this.collectV3AgentStream(response, requestExecutionContext);
2739
2822
  // Auto-continuation: if the agent checkpointed (budget exceeded), continue automatically
@@ -2761,7 +2844,6 @@ document.addEventListener('DOMContentLoaded', () => {
2761
2844
  };
2762
2845
  const continueController = new AbortController();
2763
2846
  const continueTimeoutId = setTimeout(() => continueController.abort(), timeoutMs);
2764
- const continueSoftTimeoutId = softTimeoutMs > 0 ? setTimeout(() => continueController.abort(), softTimeoutMs) : null;
2765
2847
  try {
2766
2848
  const continueHeaders = await this.getV3AgentHeaders();
2767
2849
  const continueResponse = await fetch(this.getV3AgentContinueUrl(baseUrl), {
@@ -2770,7 +2852,6 @@ document.addEventListener('DOMContentLoaded', () => {
2770
2852
  body: JSON.stringify(continueBody),
2771
2853
  signal: continueController.signal,
2772
2854
  });
2773
- clearTimeout(continueTimeoutId);
2774
2855
  if (!continueResponse.ok) {
2775
2856
  break; // Fall through to normal completion with partial data
2776
2857
  }
@@ -2781,9 +2862,6 @@ document.addEventListener('DOMContentLoaded', () => {
2781
2862
  }
2782
2863
  finally {
2783
2864
  clearTimeout(continueTimeoutId);
2784
- if (continueSoftTimeoutId) {
2785
- clearTimeout(continueSoftTimeoutId);
2786
- }
2787
2865
  }
2788
2866
  }
2789
2867
  // Use the final continuation data for workspace recovery
@@ -2838,9 +2916,6 @@ document.addEventListener('DOMContentLoaded', () => {
2838
2916
  }
2839
2917
  finally {
2840
2918
  clearTimeout(timeoutId);
2841
- if (softTimeoutId) {
2842
- clearTimeout(softTimeoutId);
2843
- }
2844
2919
  }
2845
2920
  }
2846
2921
  lastErrors = errors;
@@ -2862,6 +2937,24 @@ document.addEventListener('DOMContentLoaded', () => {
2862
2937
  this.config.clearAuth();
2863
2938
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
2864
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
+ }
2865
2958
  throw new Error(errors.join(' | '));
2866
2959
  }
2867
2960
  formatOperatorResponse(data = {}) {
@@ -2922,7 +3015,7 @@ document.addEventListener('DOMContentLoaded', () => {
2922
3015
  workspace: { path: workspacePath },
2923
3016
  workspace_path: workspacePath,
2924
3017
  workspace_summary: workspaceSummary,
2925
- model: this.resolveModelId(executionContext.model || 'code-9b'),
3018
+ model: this.resolveModelId(executionContext.model || 'code'),
2926
3019
  history: executionContext.history || [],
2927
3020
  executionSurface: executionContext.executionSurface || 'cli',
2928
3021
  clientSurface: executionContext.clientSurface || 'cli',
@@ -2933,7 +3026,7 @@ document.addEventListener('DOMContentLoaded', () => {
2933
3026
  rawPrompt: executionContext.rawPrompt || null,
2934
3027
  requestStartedAt: executionContext.requestStartedAt,
2935
3028
  },
2936
- workflow_type: executionContext.workflowType || 'analysis_only',
3029
+ workflow_type: executionContext.workflowType || 'full',
2937
3030
  options: {
2938
3031
  stream: true,
2939
3032
  save_to_vigflow: executionContext.savePlanToVigFlow === true,
@@ -2943,7 +3036,7 @@ document.addEventListener('DOMContentLoaded', () => {
2943
3036
  });
2944
3037
  if (!response.ok) {
2945
3038
  const errorText = await response.text().catch(() => '');
2946
- throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
3039
+ throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2947
3040
  }
2948
3041
  if (!response.body || typeof response.body.getReader !== 'function') {
2949
3042
  const fallbackData = await response.json();
@@ -3097,7 +3190,7 @@ document.addEventListener('DOMContentLoaded', () => {
3097
3190
  if (!this.shouldSkipCloudRoutes(resolvedModel)) {
3098
3191
  try {
3099
3192
  this.logger.debug(`Direct Vigthoria Models API: ${resolvedModel}`);
3100
- const token = this.getAccessToken();
3193
+ const token = this.config.get('authToken');
3101
3194
  const response = await this.modelRouterClient.post('/v1/chat/completions', {
3102
3195
  model: resolvedModel,
3103
3196
  messages,
@@ -3211,18 +3304,20 @@ document.addEventListener('DOMContentLoaded', () => {
3211
3304
  }
3212
3305
  getFallbackModelId(resolvedModel) {
3213
3306
  const cloudModels = new Set([
3307
+ 'deepseek-v3.1:671b-cloud',
3214
3308
  'moonshotai/kimi-k2.5',
3215
3309
  'vigthoria-cloud-pro',
3216
3310
  'vigthoria-cloud-k2',
3217
3311
  'vigthoria-cloud-ultra',
3218
3312
  ]);
3219
3313
  if (cloudModels.has(resolvedModel)) {
3220
- return 'vigthoria-v3-code-35b';
3314
+ return 'vigthoria-v3-code-30b';
3221
3315
  }
3222
3316
  return null;
3223
3317
  }
3224
3318
  isCloudModelId(resolvedModel) {
3225
- return resolvedModel === 'moonshotai/kimi-k2.5'
3319
+ return resolvedModel === 'deepseek-v3.1:671b-cloud'
3320
+ || resolvedModel === 'moonshotai/kimi-k2.5'
3226
3321
  || resolvedModel === 'vigthoria-cloud-pro'
3227
3322
  || resolvedModel === 'vigthoria-cloud-k2'
3228
3323
  || resolvedModel === 'vigthoria-cloud-ultra';
@@ -3232,25 +3327,11 @@ document.addEventListener('DOMContentLoaded', () => {
3232
3327
  }
3233
3328
  resolvePermittedModelId(shortName) {
3234
3329
  const resolvedModel = this.resolveModelId(shortName);
3235
- const requested = String(shortName || '').toLowerCase();
3236
3330
  if (this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()) {
3237
3331
  const fallbackModel = this.getSelfHostedFallbackModelId(resolvedModel, shortName);
3238
3332
  this.logger.debug(`Blocked unauthorized cloud model ${shortName}; using fallback ${fallbackModel}`);
3239
3333
  return fallbackModel;
3240
3334
  }
3241
- const blockedRequestedModels = new Set(['fast', 'mini', 'creative', 'creative-v3', 'creative-v4']);
3242
- const blockedResolvedModels = new Set([
3243
- 'vigthoria-creative-9b-v4',
3244
- 'vigthoria-fast-1.7b',
3245
- 'vigthoria-mini-0.6b',
3246
- 'vigthoria_p1_m',
3247
- 'vigthoria_r1_s'
3248
- ]);
3249
- if (blockedRequestedModels.has(requested) || blockedResolvedModels.has(resolvedModel)) {
3250
- const fallbackModel = 'vigthoria-v3-code-35b';
3251
- this.logger.warn(`Model ${shortName} is not permitted for CLI operational workflows; using ${fallbackModel}`);
3252
- return fallbackModel;
3253
- }
3254
3335
  return resolvedModel;
3255
3336
  }
3256
3337
  shouldSimulateCloudFailure() {
@@ -3269,29 +3350,27 @@ document.addEventListener('DOMContentLoaded', () => {
3269
3350
  isSelfHostedPreferredModel(resolvedModel, requestedModel) {
3270
3351
  const normalizedRequested = String(requestedModel || '').toLowerCase();
3271
3352
  const selfHostedModels = new Set([
3272
- 'vigthoria-v3-code-35b',
3273
- 'vigthoria-v3-code-35b:latest',
3353
+ 'vigthoria-v3-code-30b',
3354
+ 'vigthoria-v3-code-30b:latest',
3274
3355
  'qwen3-coder:latest',
3275
- 'vigthoria_c1_m',
3356
+ 'vigthoria-v2-code-8b',
3276
3357
  ]);
3277
3358
  return selfHostedModels.has(resolvedModel)
3278
3359
  || normalizedRequested === 'agent'
3279
3360
  || normalizedRequested === 'code'
3280
- || normalizedRequested === 'code-35b'
3281
- || normalizedRequested === 'code-35b'
3282
- || normalizedRequested === 'code-9b'
3361
+ || normalizedRequested === 'code-30b'
3283
3362
  || normalizedRequested === 'pro';
3284
3363
  }
3285
3364
  getSelfHostedFallbackModelId(resolvedModel, requestedModel) {
3286
3365
  if (this.isSelfHostedPreferredModel(resolvedModel, requestedModel)) {
3287
- return resolvedModel === 'qwen3-coder:latest' ? 'vigthoria-v3-code-35b' : resolvedModel;
3366
+ return resolvedModel === 'qwen3-coder:latest' ? 'vigthoria-v3-code-30b' : resolvedModel;
3288
3367
  }
3289
- return 'vigthoria-v3-code-35b';
3368
+ return 'vigthoria-v3-code-30b';
3290
3369
  }
3291
3370
  // Streaming chat
3292
3371
  async *chatStream(messages, model) {
3293
3372
  const wsUrl = this.config.get('wsUrl');
3294
- const token = this.getAccessToken();
3373
+ const token = this.config.get('authToken');
3295
3374
  return new Promise((resolve, reject) => {
3296
3375
  const ws = new ws_1.default(`${wsUrl}/chat`, {
3297
3376
  headers: { Authorization: `Bearer ${token}` },
@@ -3320,7 +3399,7 @@ document.addEventListener('DOMContentLoaded', () => {
3320
3399
  // Non-streaming alternative with callback
3321
3400
  async chatWithCallback(messages, model, onChunk, onDone, onError) {
3322
3401
  const wsUrl = this.config.get('wsUrl');
3323
- const token = this.getAccessToken();
3402
+ const token = this.config.get('authToken');
3324
3403
  return new Promise((resolve, reject) => {
3325
3404
  const ws = new ws_1.default(`${wsUrl}/chat`, {
3326
3405
  headers: { Authorization: `Bearer ${token}` },
@@ -3369,7 +3448,7 @@ document.addEventListener('DOMContentLoaded', () => {
3369
3448
  // (/v1/chat/completions on api.vigthoria.io) which is the only
3370
3449
  // backend that reliably accepts our auth token.
3371
3450
  async chatComplete(systemPrompt, userPrompt, model, maxTokens) {
3372
- const resolvedModel = model ? this.resolvePermittedModelId(model) : 'vigthoria-v3-code-35b';
3451
+ const resolvedModel = model ? this.resolvePermittedModelId(model) : 'vigthoria-v3-code-30b';
3373
3452
  const response = await this.modelRouterClient.post('/v1/chat/completions', {
3374
3453
  model: resolvedModel,
3375
3454
  messages: [
@@ -3449,21 +3528,76 @@ document.addEventListener('DOMContentLoaded', () => {
3449
3528
  * Ensure code has balanced curly braces by appending missing closing braces.
3450
3529
  */
3451
3530
  ensureBalancedBraces(code) {
3452
- let depth = 0;
3453
- 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
+ }
3454
3566
  if (ch === '{')
3455
- depth++;
3567
+ braces++;
3456
3568
  else if (ch === '}')
3457
- 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;
3458
3597
  }
3459
- if (depth > 0) {
3460
- let result = code.trimEnd();
3461
- for (let i = 0; i < depth; i++) {
3462
- result += '\n}';
3463
- }
3464
- code = result;
3598
+ catch {
3599
+ return false;
3465
3600
  }
3466
- return code;
3467
3601
  }
3468
3602
  /**
3469
3603
  * Extract the first complete function/class from code.
@@ -3571,7 +3705,17 @@ document.addEventListener('DOMContentLoaded', () => {
3571
3705
  }
3572
3706
  }
3573
3707
  async explainCode(code, language) {
3574
- 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');
3575
3719
  return this.chatComplete(sysPrompt, code);
3576
3720
  }
3577
3721
  async reviewCode(code, language) {
@@ -3583,9 +3727,11 @@ document.addEventListener('DOMContentLoaded', () => {
3583
3727
  'Rules:',
3584
3728
  '- Return concrete, line-specific issues with severity.',
3585
3729
  '- Every issue MUST reference a line number.',
3586
- '- 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.',
3587
3733
  '- Prioritize REAL BUGS: wrong operators, logic errors, off-by-one, type mismatches.',
3588
- '- 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.',
3589
3735
  '- Return ONLY the JSON object, no markdown fences or extra text.',
3590
3736
  ].join('\n');
3591
3737
  let raw = {};
@@ -3600,25 +3746,40 @@ document.addEventListener('DOMContentLoaded', () => {
3600
3746
  const score = typeof raw.score === 'number' ? raw.score : 0;
3601
3747
  const issues = Array.isArray(raw.issues) ? raw.issues : [];
3602
3748
  const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
3603
- // Always run client-side heuristics and merge any findings the
3604
- // server missed. This ensures arithmetic/logic bugs are surfaced
3605
- // 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');
3606
3752
  const heuristic = this.heuristicCodeIssues(code, language);
3607
3753
  for (const h of heuristic) {
3608
- // Always include critical logic bugs (severity error) from heuristics
3609
- // regardless of server results these catch wrong-operator bugs the
3610
- // server frequently misses.
3611
- if (h.severity === 'error') {
3612
- const exactDuplicate = issues.some((existing) => existing.line === h.line && existing.message === h.message);
3613
- if (!exactDuplicate) {
3614
- issues.push(h);
3615
- }
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')
3616
3757
  continue;
3617
- }
3618
- // For non-critical heuristics, avoid duplicating issues the server
3619
- // already reported on the same line with the same type.
3620
- const isDuplicate = issues.some((existing) => existing.line === h.line && existing.type === h.type);
3621
- 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) {
3622
3783
  issues.push(h);
3623
3784
  }
3624
3785
  }
@@ -3767,9 +3928,11 @@ document.addEventListener('DOMContentLoaded', () => {
3767
3928
  const sysPrompt = [
3768
3929
  `You are a ${language} code fixer. Fix the code for: ${fixType}.`,
3769
3930
  'Return a JSON object with:',
3770
- ' "fixed": the corrected code as a string,',
3931
+ ' "fixed": the COMPLETE corrected source code as a string (not a snippet — the full file),',
3771
3932
  ' "changes": [{ "line": number, "before": string, "after": string, "reason": string }]',
3772
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.',
3773
3936
  '- Fix ONLY the issues related to the fix type.',
3774
3937
  '- Do not add comments, do not restructure beyond the minimal fix.',
3775
3938
  '- Return ONLY the JSON object, no markdown fences.',
@@ -3809,6 +3972,26 @@ document.addEventListener('DOMContentLoaded', () => {
3809
3972
  if (fixType === 'syntax' && fixed !== code) {
3810
3973
  fixed = this.repairBracketBalance(code, fixed);
3811
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
+ }
3812
3995
  // If there are still no changes but the fixed code differs, compute
3813
3996
  // a semantic diff using LCS so inserted/removed lines don't cause
3814
3997
  // every subsequent line to appear as changed.
@@ -4110,116 +4293,23 @@ document.addEventListener('DOMContentLoaded', () => {
4110
4293
  }
4111
4294
  // Model resolution - maps Vigthoria model names to internal IDs
4112
4295
  // INTERNAL USE ONLY - users see only Vigthoria branding
4113
- /**
4114
- * Build workspace summary re-ordered by semantic relevance to the prompt.
4115
- * Changed files are listed first, then keyword-matched files, then the rest.
4116
- * Falls back to plain buildLocalWorkspaceSummary when no prompt is provided.
4117
- */
4118
- buildSemanticWorkspaceSummary(workspacePath, prompt) {
4119
- const summary = this.buildLocalWorkspaceSummary(workspacePath);
4120
- if (!summary?.workspaceFiles || !prompt)
4121
- return summary;
4122
- try {
4123
- const { topFiles } = (0, context_ranker_js_1.buildSemanticContext)(workspacePath, prompt, 15);
4124
- if (topFiles.length === 0)
4125
- return summary;
4126
- const prioritySet = new Set(topFiles.map(f => f.path));
4127
- const allFiles = summary.workspaceFiles;
4128
- const reordered = {};
4129
- // Semantically ranked files first
4130
- for (const f of topFiles) {
4131
- if (allFiles[f.path] !== undefined)
4132
- reordered[f.path] = allFiles[f.path];
4133
- }
4134
- // Remaining files after priority set
4135
- for (const [p, c] of Object.entries(allFiles)) {
4136
- if (!prioritySet.has(p))
4137
- reordered[p] = c;
4138
- }
4139
- return { ...summary, workspaceFiles: reordered };
4140
- }
4141
- catch {
4142
- return summary;
4143
- }
4144
- }
4145
- /**
4146
- * Self-healing cycle: run post-write validators and, if errors are found,
4147
- * send a targeted correction prompt to the V3 agent (max one healing round).
4148
- *
4149
- * This is a best-effort operation — failures never propagate to the user as
4150
- * hard errors; they are surfaced as a status line in the terminal output.
4151
- */
4152
- async runSelfHealingCycle(originalPrompt, workspacePath, context = {}) {
4153
- // Guard: don't heal analysis tasks or recursive healing rounds
4154
- if (context._isHealingRound || this.isAnalysisOnlyTask(originalPrompt, context)) {
4155
- return { healingAttempted: false, passed: true, tool: 'none' };
4156
- }
4157
- let validations = [];
4158
- try {
4159
- validations = await (0, post_write_validator_js_1.runPostWriteValidation)(workspacePath);
4160
- }
4161
- catch {
4162
- return { healingAttempted: false, passed: true, tool: 'none' };
4163
- }
4164
- const failures = validations.filter(v => v.ran && !v.passed);
4165
- if (failures.length === 0) {
4166
- // All validators passed — update cache to reflect current state
4167
- try {
4168
- const { getAgentWorkspaceSnapshot } = this;
4169
- if (typeof getAgentWorkspaceSnapshot === 'function') {
4170
- const snap = getAgentWorkspaceSnapshot.call(this, workspacePath);
4171
- if (snap?.paths?.length > 0)
4172
- (0, workspace_cache_js_1.updateWorkspaceCache)(workspacePath, snap.paths);
4173
- }
4174
- }
4175
- catch { /* non-fatal */ }
4176
- return { healingAttempted: false, passed: true, tool: 'none' };
4177
- }
4178
- const errorText = (0, post_write_validator_js_1.formatValidationErrors)(failures);
4179
- const healPrompt = `The code you just generated has the following validation errors. Fix ONLY these errors — do not change anything else:
4180
-
4181
- ${errorText}
4182
-
4183
- Apply the minimum change needed to make the validator pass.`;
4184
- try {
4185
- await this.runV3AgentWorkflow(healPrompt, {
4186
- ...context,
4187
- workspacePath,
4188
- projectPath: workspacePath,
4189
- targetPath: workspacePath,
4190
- agentTaskType: 'debugging',
4191
- agentTimeoutMs: 90_000,
4192
- _isHealingRound: true,
4193
- });
4194
- // Re-run validators to check healing success
4195
- const recheck = await (0, post_write_validator_js_1.runPostWriteValidation)(workspacePath);
4196
- const passed = recheck.filter(r => r.ran).every(r => r.passed);
4197
- return { healingAttempted: true, passed, tool: failures.map(f => f.tool).join('+') };
4198
- }
4199
- catch {
4200
- return { healingAttempted: true, passed: false, tool: failures.map(f => f.tool).join('+') };
4201
- }
4202
- }
4203
4296
  resolveModelId(shortName) {
4204
4297
  const modelMap = {
4205
4298
  // ═══════════════════════════════════════════════════════════════
4206
4299
  // VIGTHORIA LOCAL - Self-hosted models
4207
4300
  // ═══════════════════════════════════════════════════════════════
4208
- 'fast': 'vigthoria-v3-code-35b',
4209
- 'mini': 'vigthoria-v3-code-35b',
4210
- 'balanced': 'vigthoria_master',
4211
- 'balanced-4b': 'vigthoria-balanced-4b',
4212
- 'creative': 'vigthoria-v3-code-35b',
4213
- // Code models
4214
- 'code': 'vigthoria-v3-code-35b',
4215
- 'code-30b': 'vigthoria-v3-code-35b',
4216
- 'code-35b': 'vigthoria-v3-code-35b',
4217
- 'code-8b': 'vigthoria_c1_m',
4218
- 'code-9b': 'vigthoria_c1_m',
4219
- 'pro': 'vigthoria-v3-code-35b',
4220
- 'agent': 'vigthoria-v3-code-35b',
4221
- 'vigthoria-code': 'vigthoria-v3-code-35b',
4222
- 'vigthoria-agent': 'vigthoria-v3-code-35b',
4301
+ 'fast': 'vigthoria-fast-1.7b',
4302
+ 'mini': 'vigthoria-mini-0.6b',
4303
+ 'balanced': 'vigthoria-balanced-4b',
4304
+ 'creative': 'vigthoria-creative-9b-v4',
4305
+ // Code Models - 30B is the default powerhouse
4306
+ 'code': 'vigthoria-v3-code-30b', // Internal: self-hosted 30B on Blackwell
4307
+ 'code-30b': 'vigthoria-v3-code-30b',
4308
+ 'code-8b': 'vigthoria-v2-code-8b',
4309
+ 'pro': 'vigthoria-v3-code-30b',
4310
+ 'agent': 'vigthoria-v3-code-30b',
4311
+ 'vigthoria-code': 'vigthoria-v3-code-30b',
4312
+ 'vigthoria-agent': 'vigthoria-v3-code-30b',
4223
4313
  // ═══════════════════════════════════════════════════════════════
4224
4314
  // VIGTHORIA CLOUD - Premium cloud models (internal routing)
4225
4315
  // ═══════════════════════════════════════════════════════════════
@@ -4227,17 +4317,18 @@ Apply the minimum change needed to make the validator pass.`;
4227
4317
  'cloud-reason': 'vigthoria-cloud-k2',
4228
4318
  'ultra': 'vigthoria-cloud-ultra',
4229
4319
  };
4320
+ // If already a full model ID, return as-is
4230
4321
  if (shortName.includes('vigthoria') || shortName.includes('/') || shortName.includes(':')) {
4231
4322
  if (modelMap[shortName]) {
4232
4323
  return modelMap[shortName];
4233
4324
  }
4234
4325
  return shortName;
4235
4326
  }
4236
- return modelMap[shortName] || 'vigthoria-v3-code-35b';
4327
+ return modelMap[shortName] || 'vigthoria-v3-code-30b';
4237
4328
  }
4238
4329
  async getCoderHealth() {
4239
4330
  try {
4240
- const response = await this.client.get('/api/health', { timeout: 10000 });
4331
+ const response = await this.client.get('/api/health', { timeout: 5000 });
4241
4332
  const ok = response.data?.status === 'ok' || response.data?.healthy === true;
4242
4333
  return {
4243
4334
  name: 'Coder API',
@@ -4259,8 +4350,8 @@ Apply the minimum change needed to make the validator pass.`;
4259
4350
  const modelsApiUrl = this.config.get('modelsApiUrl');
4260
4351
  try {
4261
4352
  const [healthResponse, modelsResponse] = await Promise.all([
4262
- this.modelRouterClient.get('/health', { timeout: 10000 }),
4263
- this.modelRouterClient.get('/v1/models', { timeout: 15000 }),
4353
+ this.modelRouterClient.get('/health', { timeout: 5000 }),
4354
+ this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
4264
4355
  ]);
4265
4356
  const healthOk = healthResponse.data?.status === 'healthy'
4266
4357
  || healthResponse.data?.status === 'ok'
@@ -4291,7 +4382,7 @@ Apply the minimum change needed to make the validator pass.`;
4291
4382
  return null;
4292
4383
  }
4293
4384
  try {
4294
- const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 10000 });
4385
+ const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 5000 });
4295
4386
  const ok = response.data?.status === 'healthy'
4296
4387
  || response.data?.status === 'ok'
4297
4388
  || response.data?.healthy === true;
@@ -4311,29 +4402,6 @@ Apply the minimum change needed to make the validator pass.`;
4311
4402
  };
4312
4403
  }
4313
4404
  }
4314
- async attemptV3ServiceRecovery(reason = '', options = {}) {
4315
- const attempts = Math.max(1, Number(options.attempts || 2));
4316
- const delayMs = Math.max(0, Number(options.delayMs || 1200));
4317
- let lastError = '';
4318
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
4319
- const health = await this.getV3AgentHealth();
4320
- if (health.ok) {
4321
- const msg = attempt === 1
4322
- ? 'V3 service is reachable.'
4323
- : `V3 service recovered after retry ${attempt}.`;
4324
- return { recovered: true, message: msg, endpoint: health.endpoint };
4325
- }
4326
- lastError = health.error || 'health probe failed';
4327
- if (attempt < attempts && delayMs > 0) {
4328
- await new Promise((resolve) => setTimeout(resolve, delayMs));
4329
- }
4330
- }
4331
- const reasonText = sanitizeUserFacingErrorText(reason || lastError || 'unknown failure');
4332
- return {
4333
- recovered: false,
4334
- message: reasonText ? `Recovery failed: ${reasonText}` : 'Recovery failed: V3 service is still unreachable.',
4335
- };
4336
- }
4337
4405
  async getV3AgentHealth() {
4338
4406
  const baseUrl = this.getV3AgentBaseUrls()[0];
4339
4407
  // Try multiple health endpoint patterns — the V3 backend may expose
@@ -4347,7 +4415,7 @@ Apply the minimum change needed to make the validator pass.`;
4347
4415
  for (const endpoint of candidates) {
4348
4416
  try {
4349
4417
  const controller = new AbortController();
4350
- const timer = setTimeout(() => controller.abort(), 8000);
4418
+ const timer = setTimeout(() => controller.abort(), 3000);
4351
4419
  const response = await fetch(endpoint, {
4352
4420
  method: 'GET',
4353
4421
  headers,
@@ -4395,7 +4463,7 @@ Apply the minimum change needed to make the validator pass.`;
4395
4463
  const runUrl = this.getV3AgentRunUrl(baseUrl);
4396
4464
  try {
4397
4465
  const controller = new AbortController();
4398
- const timer = setTimeout(() => controller.abort(), 5000);
4466
+ const timer = setTimeout(() => controller.abort(), 2000);
4399
4467
  const probe = await fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
4400
4468
  clearTimeout(timer);
4401
4469
  if (probe.ok || probe.status === 204 || probe.status === 405) {
@@ -4518,6 +4586,20 @@ Apply the minimum change needed to make the validator pass.`;
4518
4586
  };
4519
4587
  }
4520
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
+ }
4521
4603
  async getDevtoolsBridgeStatus() {
4522
4604
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
4523
4605
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
@@ -4552,11 +4634,18 @@ Apply the minimum change needed to make the validator pass.`;
4552
4634
  });
4553
4635
  }
4554
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
+ ]);
4555
4644
  const [v3Agent, hyperLoop, repoMemory, devtoolsBridge] = await Promise.all([
4556
- this.getV3AgentHealth(),
4557
- this.getHyperLoopHealth(),
4558
- this.getRepoMemoryHealth(context),
4559
- 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'),
4560
4649
  ]);
4561
4650
  return {
4562
4651
  overallOk: v3Agent.ok && hyperLoop.ok && repoMemory.ok,