vigthoria-cli 1.8.15 → 1.9.2

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,11 +8,18 @@ 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;
11
14
  exports.classifyError = classifyError;
12
15
  exports.formatCLIError = formatCLIError;
13
16
  exports.sanitizeUserFacingErrorText = sanitizeUserFacingErrorText;
14
17
  exports.isServerRuntime = isServerRuntime;
15
18
  exports.describeUpstreamStatus = describeUpstreamStatus;
19
+ exports.validateJwtExpiry = validateJwtExpiry;
20
+ exports.validateJwt = validateJwt;
21
+ exports.refreshJwtIfNeeded = refreshJwtIfNeeded;
22
+ exports.createApiClient = createApiClient;
16
23
  const axios_1 = __importDefault(require("axios"));
17
24
  const crypto_1 = require("crypto");
18
25
  const fs_1 = __importDefault(require("fs"));
@@ -20,26 +27,202 @@ const https_1 = __importDefault(require("https"));
20
27
  const net_1 = __importDefault(require("net"));
21
28
  const path_1 = __importDefault(require("path"));
22
29
  const ws_1 = __importDefault(require("ws"));
23
- const workspace_stream_js_1 = require("./workspace-stream.js");
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");
24
34
  class CLIError extends Error {
25
35
  category;
26
36
  statusCode;
27
37
  endpoint;
38
+ code;
39
+ details;
40
+ isCritical;
28
41
  constructor(message, category, opts) {
29
42
  super(message);
30
43
  this.name = 'CLIError';
31
44
  this.category = category;
32
45
  this.statusCode = opts?.statusCode;
33
46
  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);
34
50
  if (opts?.cause)
35
51
  this.cause = opts.cause;
36
52
  }
37
53
  }
38
54
  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
+ }
39
214
  /** Classify an axios or fetch error into a structured CLIError. */
40
215
  function classifyError(error, fallbackCategory = 'network') {
41
216
  if (error instanceof CLIError)
42
217
  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
+ }
43
226
  const axErr = error;
44
227
  const status = axErr?.response?.status;
45
228
  const endpoint = axErr?.config?.url || axErr?.config?.baseURL || '';
@@ -92,36 +275,7 @@ function formatCLIError(err) {
92
275
  return `${tag} ${err.message}`;
93
276
  }
94
277
  }
95
- const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
96
- const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS;
97
- if (!rawValue) {
98
- // No total timeout by default for long-running SSE agent workflows.
99
- return 0;
100
- }
101
- const parsed = Number.parseInt(rawValue, 10);
102
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
103
- })();
104
- const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
105
- const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS;
106
- if (!rawValue) {
107
- // Keep stream open indefinitely unless user configures an idle limit.
108
- return 0;
109
- }
110
- const parsed = Number.parseInt(rawValue, 10);
111
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
112
- })();
113
- const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
114
- const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS;
115
- if (!rawValue) {
116
- // BMAD/operator flows can be long-running; do not cap by default.
117
- return 0;
118
- }
119
- const parsed = Number.parseInt(rawValue, 10);
120
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
121
- })();
122
278
  // Sanitize an upstream error string before exposing it to the end user.
123
- // Strips URLs, IPs:ports, absolute server paths, and bare hostnames so the
124
- // CLI never reveals internal infrastructure to remote users.
125
279
  function sanitizeUserFacingErrorText(input) {
126
280
  if (!input)
127
281
  return '';
@@ -131,19 +285,13 @@ function sanitizeUserFacingErrorText(input) {
131
285
  out = out.replace(/\b(?:localhost|127\.0\.0\.1)(?::\d+)?\b/gi, '[redacted-host]');
132
286
  out = out.replace(/\b[a-z0-9.-]+\.vigthoria\.io\b/gi, '[redacted-host]');
133
287
  out = out.replace(/(?:[A-Za-z]:)?[\\/](?:var|opt|tmp|home|root|etc|usr)[\\/][^\s'"<>)]*/gi, '[redacted-path]');
134
- // Windows drive-letter paths (e.g. C:\Users\Name\AppData\...).
135
288
  out = out.replace(/[A-Za-z]:\\[^\s'"<>)]+/g, '[redacted-path]');
136
- // UNC paths (\\server\share\...).
137
289
  out = out.replace(/\\\\[^\s'"<>)]+/g, '[redacted-path]');
138
- out = out.replace(/\{\s*"detail"\s*:\s*"[^"]*"\s*\}/g, '');
139
290
  out = out.replace(/\s+/g, ' ').trim();
140
291
  if (out.length > 160)
141
292
  out = out.slice(0, 160) + '...';
142
293
  return out;
143
294
  }
144
- // True only when this CLI process is running on the Vigthoria server itself.
145
- // Local user installations must NEVER attempt internal loopback endpoints,
146
- // because the resulting fetch errors include the URL we tried (leak vector).
147
295
  function isServerRuntime() {
148
296
  if (process.env.VIGTHORIA_RUN_MODE === 'server')
149
297
  return true;
@@ -166,15 +314,144 @@ function describeUpstreamStatus(status) {
166
314
  return 'Request was rejected by the service.';
167
315
  return 'Unexpected response from service.';
168
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
+ }
347
+ }
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 });
422
+ },
423
+ };
424
+ }
425
+ const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
426
+ const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS || '1200000';
427
+ const parsed = Number.parseInt(rawValue, 10);
428
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1200000;
429
+ })();
430
+ const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
431
+ const rawValue = process.env.VIGTHORIA_AGENT_IDLE_TIMEOUT_MS || process.env.V3_AGENT_IDLE_TIMEOUT_MS || '90000';
432
+ const parsed = Number.parseInt(rawValue, 10);
433
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
434
+ })();
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
+ const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
441
+ const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS || '300000';
442
+ const parsed = Number.parseInt(rawValue, 10);
443
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
444
+ })();
169
445
  class APIClient {
170
446
  client;
171
447
  modelRouterClient;
172
448
  selfHostedModelRouterClient;
173
449
  config;
450
+ token = null;
451
+ expiresAt = null;
174
452
  logger;
175
453
  ws = null;
176
454
  vigFlowTokens = new Map();
177
- _httpsAgent = null;
178
455
  constructor(config, logger) {
179
456
  this.config = config;
180
457
  this.logger = logger;
@@ -184,7 +461,6 @@ class APIClient {
184
461
  keepAlive: true,
185
462
  timeout: 30000,
186
463
  });
187
- this._httpsAgent = httpsAgent;
188
464
  // Main Vigthoria Coder API (coder.vigthoria.io)
189
465
  this.client = axios_1.default.create({
190
466
  baseURL: config.get('apiUrl'),
@@ -211,47 +487,82 @@ class APIClient {
211
487
  this.selfHostedModelRouterClient = selfHostedModelsApiUrl ? axios_1.default.create({
212
488
  baseURL: selfHostedModelsApiUrl,
213
489
  timeout: 240000,
214
- httpsAgent,
215
490
  headers: {
216
491
  'Content-Type': 'application/json',
217
492
  'User-Agent': `Vigthoria-CLI/${process.env.npm_package_version || '1.6.9'}`,
218
493
  },
219
494
  }) : null;
220
- // Add auth interceptor
221
- this.client.interceptors.request.use((req) => {
222
- const token = this.config.get('authToken');
223
- if (token) {
224
- req.headers.Authorization = `Bearer ${token}`;
225
- req.headers.Cookie = `vigthoria-auth-token=${token}`;
226
- }
227
- return req;
228
- });
229
- this.modelRouterClient.interceptors.request.use((req) => {
230
- const token = this.config.get('authToken');
231
- if (token) {
232
- req.headers.Authorization = `Bearer ${token}`;
233
- req.headers.Cookie = `vigthoria-auth-token=${token}`;
234
- }
235
- return req;
236
- });
237
- this.selfHostedModelRouterClient?.interceptors.request.use((req) => {
238
- const token = this.config.get('authToken');
239
- if (token) {
240
- req.headers.Authorization = `Bearer ${token}`;
241
- }
242
- return req;
243
- });
244
- // Add response interceptors for token refresh + structured errors
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.
245
522
  const createAuthRetryInterceptor = (client) => {
246
523
  client.interceptors.response.use((res) => res, async (error) => {
247
- if (error.response?.status === 401) {
248
- const refreshed = await this.refreshToken();
249
- if (refreshed && error.config) {
250
- return client.request(error.config);
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
+ });
251
550
  }
252
- throw classifyError(error, 'auth');
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
+ });
253
565
  }
254
- throw classifyError(error);
255
566
  });
256
567
  };
257
568
  createAuthRetryInterceptor(this.client);
@@ -260,22 +571,12 @@ class APIClient {
260
571
  createAuthRetryInterceptor(this.selfHostedModelRouterClient);
261
572
  }
262
573
  }
263
- /**
264
- * Destroy keep-alive sockets so the Node.js event loop can drain
265
- * naturally. Call this before exiting commands that run HTTP probes
266
- * (e.g. `status`) to avoid the libuv UV_HANDLE_CLOSING assertion
267
- * on Windows / Node 25+.
268
- */
269
574
  destroy() {
270
- if (this._httpsAgent) {
271
- this._httpsAgent.destroy();
272
- this._httpsAgent = null;
273
- }
274
575
  if (this.ws) {
275
576
  try {
276
577
  this.ws.close();
277
578
  }
278
- catch { /* ok */ }
579
+ catch { }
279
580
  this.ws = null;
280
581
  }
281
582
  }
@@ -355,6 +656,26 @@ class APIClient {
355
656
  }
356
657
  }
357
658
  }
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');
358
679
  this.config.clearAuth();
359
680
  return false;
360
681
  }
@@ -364,6 +685,19 @@ class APIClient {
364
685
  return false;
365
686
  }
366
687
  }
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
+ }
367
701
  extractUserProfile(data) {
368
702
  if (!data) {
369
703
  return null;
@@ -400,9 +734,11 @@ class APIClient {
400
734
  }
401
735
  return true;
402
736
  }
403
- catch {
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}`);
404
740
  this.config.clearAuth();
405
- return false;
741
+ throw cliError;
406
742
  }
407
743
  }
408
744
  async getSubscriptionStatus() {
@@ -437,35 +773,35 @@ class APIClient {
437
773
  if (!token) {
438
774
  return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
439
775
  }
440
- // Probe both endpoints in parallel. If EITHER succeeds the token is
441
- // valid. Only if both return 401/403 is the token truly invalid.
442
- // If both are unreachable assume the token is fine (offline scenario).
443
- const results = await Promise.allSettled([
444
- this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
445
- this.client.get('/api/user/profile', { timeout: 5000 }),
446
- ]);
447
- for (const r of results) {
448
- if (r.status === 'fulfilled')
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 });
449
782
  return { valid: true };
450
- }
451
- // Both failed — check why
452
- for (const r of results) {
453
- if (r.status === 'rejected') {
454
- const err = r.reason;
455
- if (err.response?.status === 401 || err.response?.status === 403) {
783
+ }
784
+ catch (error) {
785
+ if (error instanceof CLIError && error.category === 'auth') {
456
786
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
457
787
  }
458
- if (err instanceof CLIError && err.category === 'auth') {
788
+ const axErr = error;
789
+ if (axErr.response?.status === 401 || axErr.response?.status === 403) {
459
790
  return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
460
791
  }
792
+ // Try the next authenticated endpoint before deciding this is
793
+ // a transient network/backend issue.
794
+ continue;
461
795
  }
462
796
  }
463
- // Both unreachable — don't assume token is bad
797
+ // Auth endpoints unreachable — do not misclassify as invalid token.
464
798
  return { valid: true };
465
799
  }
466
800
  getV3AgentBaseUrls(preferLocal = false) {
467
801
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
468
- const allowLocalV3Agent = isServerRuntime() && (process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1' || preferLocal);
802
+ const allowLocalV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
803
+ || this.allowLocalServiceFallbacks()
804
+ || preferLocal;
469
805
  const urls = [
470
806
  process.env.VIGTHORIA_V3_AGENT_URL,
471
807
  process.env.V3_AGENT_URL,
@@ -488,12 +824,11 @@ class APIClient {
488
824
  }
489
825
  getOperatorBaseUrls() {
490
826
  const configuredModelsApiUrl = String(this.config.get('modelsApiUrl') || 'https://api.vigthoria.io').replace(/\/$/, '');
491
- const allowLocal = isServerRuntime() && process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1';
492
827
  const urls = [
493
828
  process.env.VIGTHORIA_OPERATOR_URL,
494
829
  process.env.OPERATOR_URL,
830
+ ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4009'] : []),
495
831
  configuredModelsApiUrl,
496
- ...(allowLocal ? ['http://127.0.0.1:4009'] : []),
497
832
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
498
833
  return [...new Set(urls)];
499
834
  }
@@ -502,38 +837,44 @@ class APIClient {
502
837
  }
503
838
  getMcpBaseUrls() {
504
839
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
505
- const allowLocal = isServerRuntime() && process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1';
506
840
  const urls = [
507
841
  process.env.VIGTHORIA_MCP_URL,
508
842
  process.env.MCP_SERVER_URL,
843
+ ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4008'] : []),
509
844
  configuredApiUrl,
510
- ...(allowLocal ? ['http://127.0.0.1:4008'] : []),
511
845
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
512
846
  return [...new Set(urls)];
513
847
  }
514
848
  getVigFlowBaseUrls() {
515
849
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
516
- const allowLocal = isServerRuntime() && process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1';
850
+ // Put the remote gateway first, since local VigFlow servers are
851
+ // rarely running for end-user CLI installations. This avoids
852
+ // wasting connection-attempt time on 127.0.0.1 and hitting the
853
+ // remote gateway only after the local attempts have already
854
+ // errored — which surfaces as a confusing "last error" 404 in
855
+ // some setups.
517
856
  const urls = [
518
857
  process.env.VIGTHORIA_VIGFLOW_URL,
519
858
  process.env.VIGFLOW_URL,
520
859
  process.env.WORKFLOW_BUILDER_URL,
521
860
  `${configuredApiUrl}/api/vigflow`,
522
- ...(allowLocal ? ['http://127.0.0.1:5060', 'http://127.0.0.1:5050'] : []),
861
+ ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:5060', 'http://127.0.0.1:5050'] : []),
523
862
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
524
863
  return [...new Set(urls)];
525
864
  }
526
865
  getTemplateServiceBaseUrls() {
527
866
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
528
- const allowLocal = isServerRuntime() && process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1';
529
867
  const urls = [
530
868
  process.env.VIGTHORIA_TEMPLATE_SERVICE_URL,
531
869
  process.env.TEMPLATE_SERVICE_URL,
870
+ ...(this.allowLocalServiceFallbacks() ? ['http://127.0.0.1:4011'] : []),
532
871
  `${configuredApiUrl}/api/template-service`,
533
- ...(allowLocal ? ['http://127.0.0.1:4011'] : []),
534
872
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
535
873
  return [...new Set(urls)];
536
874
  }
875
+ allowLocalServiceFallbacks() {
876
+ return process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1' || isServerRuntime();
877
+ }
537
878
  isFrontendTask(message = '', context = {}) {
538
879
  // Never treat analysis-only tasks as frontend tasks — preview gate
539
880
  // should not fire for read-only inspection prompts.
@@ -855,7 +1196,7 @@ class APIClient {
855
1196
  });
856
1197
  if (!response.ok) {
857
1198
  const errorText = await response.text().catch(() => '');
858
- throw new Error(`Template preview proof ${response.status}: ${describeUpstreamStatus(response.status)}`);
1199
+ throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
859
1200
  }
860
1201
  const payload = await response.json();
861
1202
  const modes = payload?.modes || {};
@@ -908,6 +1249,12 @@ class APIClient {
908
1249
  'Content-Type': 'application/json',
909
1250
  Accept: 'application/json',
910
1251
  };
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
+ }
911
1258
  const authToken = this.getAccessToken();
912
1259
  if (authToken) {
913
1260
  headers.Authorization = `Bearer ${authToken}`;
@@ -919,6 +1266,12 @@ class APIClient {
919
1266
  const headers = {
920
1267
  'Content-Type': 'application/json',
921
1268
  };
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
+ }
922
1275
  const authToken = this.getAccessToken();
923
1276
  if (authToken) {
924
1277
  headers.Authorization = `Bearer ${authToken}`;
@@ -999,8 +1352,7 @@ class APIClient {
999
1352
  this.logger.debug(`VigFlow ${operation} via ${baseUrl} failed:`, lastError.message);
1000
1353
  }
1001
1354
  }
1002
- // Throw a clean message instead of the raw ECONNREFUSED from the last URL tried
1003
- throw new Error(`No VigFlow backend available for ${operation}. The workflow service is not deployed or not reachable.`);
1355
+ throw lastError || new Error(`No VigFlow backend available for ${operation}.`);
1004
1356
  }
1005
1357
  /**
1006
1358
  * Build the correct sub-path for VigFlow endpoints.
@@ -1137,7 +1489,9 @@ class APIClient {
1137
1489
  const targetPath = resolvedContext.targetPath || resolvedContext.projectPath || resolvedContext.workspacePath || resolvedContext.projectRoot || process.cwd();
1138
1490
  const localWorkspacePath = this.resolveAgentTargetPath(resolvedContext);
1139
1491
  const serverWorkspacePath = this.resolveServerBindableWorkspacePath(resolvedContext);
1140
- const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath);
1492
+ const localWorkspaceSummary = resolvedContext.rawPrompt
1493
+ ? this.buildSemanticWorkspaceSummary(localWorkspacePath, String(resolvedContext.rawPrompt))
1494
+ : this.buildLocalWorkspaceSummary(localWorkspacePath);
1141
1495
  const requestedModel = String(resolvedContext.model || resolvedContext.requestedModel || 'agent');
1142
1496
  const resolvedModel = this.resolvePermittedModelId(requestedModel);
1143
1497
  // When the server cannot directly access the workspace (e.g. Windows
@@ -1292,458 +1646,6 @@ class APIClient {
1292
1646
  const match = String(message || '').match(/called\s+([A-Z][A-Za-z0-9&\- ]{2,40})/i);
1293
1647
  return match?.[1]?.trim() || fallback;
1294
1648
  }
1295
- materializeEmergencySaaSWorkspace(message = '', context = {}) {
1296
- const rootPath = this.resolveAgentTargetPath(context);
1297
- if (!rootPath) {
1298
- return null;
1299
- }
1300
- fs_1.default.mkdirSync(rootPath, { recursive: true });
1301
- const appName = this.extractEmergencyAppName(message);
1302
- const html = `<!DOCTYPE html>
1303
- <html lang="en">
1304
- <head>
1305
- <meta charset="UTF-8">
1306
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1307
- <title>${appName}</title>
1308
- <link rel="stylesheet" href="styles.css">
1309
- </head>
1310
- <body>
1311
- <div class="app-shell">
1312
- <aside class="sidebar">
1313
- <div class="brand">${appName}</div>
1314
- <button class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation">Menu</button>
1315
- <nav>
1316
- <a href="#dashboard" class="nav-link active">Dashboard</a>
1317
- <a href="#team" class="nav-link">Team</a>
1318
- <a href="#billing" class="nav-link">Billing</a>
1319
- <a href="#settings" class="nav-link">Settings</a>
1320
- </nav>
1321
- </aside>
1322
- <main class="content">
1323
- <section class="hero-card panel active-panel" id="dashboard">
1324
- <div class="hero-copy">
1325
- <p class="eyebrow">Dashboard</p>
1326
- <h1>${appName} revenue command center</h1>
1327
- <p>Track login activity, campaign velocity, billing state, and team performance from one responsive SaaS workspace.</p>
1328
- </div>
1329
- <form class="login-card">
1330
- <h2>Login</h2>
1331
- <label>Email<input type="email" placeholder="ops@${appName.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'signaldesk'}.io"></label>
1332
- <label>Password<input type="password" placeholder="Enter password"></label>
1333
- <button type="submit">Enter dashboard</button>
1334
- </form>
1335
- </section>
1336
-
1337
- <section class="stats-grid">
1338
- <article class="stat-card"><span>MRR</span><strong>$284K</strong><em>+12.4%</em></article>
1339
- <article class="stat-card"><span>Activation</span><strong>74%</strong><em>+6.1%</em></article>
1340
- <article class="stat-card"><span>Team Seats</span><strong>128</strong><em>8 pending</em></article>
1341
- <article class="stat-card"><span>Churn Risk</span><strong>2.1%</strong><em>Low</em></article>
1342
- </section>
1343
-
1344
- <section class="workspace-grid">
1345
- <article class="panel chart-panel">
1346
- <div class="panel-header">
1347
- <h2>Analytics</h2>
1348
- <button id="open-modal" type="button">Add campaign</button>
1349
- </div>
1350
- <div class="chart-bars" aria-label="Revenue chart">
1351
- <div class="bar" style="--value: 52%"><span>Mon</span></div>
1352
- <div class="bar" style="--value: 68%"><span>Tue</span></div>
1353
- <div class="bar" style="--value: 74%"><span>Wed</span></div>
1354
- <div class="bar" style="--value: 59%"><span>Thu</span></div>
1355
- <div class="bar" style="--value: 88%"><span>Fri</span></div>
1356
- </div>
1357
- </article>
1358
-
1359
- <article class="panel activity-panel">
1360
- <div class="panel-header"><h2>Activity Feed</h2><span>Live</span></div>
1361
- <ul class="activity-feed">
1362
- <li><strong>Billing</strong><span>Enterprise invoice paid</span></li>
1363
- <li><strong>Team</strong><span>New strategist invited to workspace</span></li>
1364
- <li><strong>Dashboard</strong><span>KPI threshold updated for activation alerts</span></li>
1365
- </ul>
1366
- </article>
1367
-
1368
- <article class="panel" id="team">
1369
- <div class="panel-header"><h2>Team Management</h2><span>Owners and operators</span></div>
1370
- <div class="team-list">
1371
- <div><strong>Ana</strong><span>Growth lead</span></div>
1372
- <div><strong>Marcus</strong><span>Billing admin</span></div>
1373
- <div><strong>Lina</strong><span>Lifecycle analyst</span></div>
1374
- </div>
1375
- </article>
1376
-
1377
- <article class="panel" id="billing">
1378
- <div class="panel-header"><h2>Billing</h2><span>Current plan</span></div>
1379
- <div class="billing-card">
1380
- <strong>Scale Annual</strong>
1381
- <p>Renews on 12 Oct with usage-based analytics overages.</p>
1382
- <button type="button" class="secondary-action">Update payment method</button>
1383
- </div>
1384
- </article>
1385
-
1386
- <article class="panel" id="settings">
1387
- <div class="panel-header"><h2>Settings</h2><span>Automation and alerts</span></div>
1388
- <form class="settings-form">
1389
- <label>Alert threshold<input type="number" value="18"></label>
1390
- <label>Weekly digest<select><option>Enabled</option><option>Paused</option></select></label>
1391
- <button type="submit">Save settings</button>
1392
- </form>
1393
- </article>
1394
- </section>
1395
- </main>
1396
- </div>
1397
-
1398
- <dialog id="campaign-modal">
1399
- <form method="dialog" class="modal-form">
1400
- <h2>Launch campaign</h2>
1401
- <label>Name<input type="text" placeholder="Retention push"></label>
1402
- <label>Owner<input type="text" placeholder="Lina"></label>
1403
- <menu>
1404
- <button value="cancel">Cancel</button>
1405
- <button value="confirm">Create</button>
1406
- </menu>
1407
- </form>
1408
- </dialog>
1409
-
1410
- <script src="scripts.js"></script>
1411
- </body>
1412
- </html>
1413
- `;
1414
- const css = `:root {
1415
- --bg: #f2ede4;
1416
- --ink: #18222f;
1417
- --muted: #5c6674;
1418
- --panel: rgba(255, 255, 255, 0.82);
1419
- --line: rgba(24, 34, 47, 0.08);
1420
- --accent: #b6542c;
1421
- --accent-strong: #7f3417;
1422
- --shadow: 0 24px 60px rgba(24, 34, 47, 0.12);
1423
- }
1424
-
1425
- * { box-sizing: border-box; }
1426
-
1427
- body {
1428
- margin: 0;
1429
- font-family: "Georgia", "Times New Roman", serif;
1430
- color: var(--ink);
1431
- background:
1432
- radial-gradient(circle at top left, rgba(182, 84, 44, 0.18), transparent 28%),
1433
- radial-gradient(circle at bottom right, rgba(24, 34, 47, 0.14), transparent 30%),
1434
- var(--bg);
1435
- }
1436
-
1437
- .app-shell {
1438
- min-height: 100vh;
1439
- display: grid;
1440
- grid-template-columns: 260px 1fr;
1441
- }
1442
-
1443
- .sidebar {
1444
- padding: 2rem 1.25rem;
1445
- background: rgba(24, 34, 47, 0.94);
1446
- color: #f7f2eb;
1447
- position: sticky;
1448
- top: 0;
1449
- min-height: 100vh;
1450
- }
1451
-
1452
- .brand {
1453
- font-size: 1.6rem;
1454
- font-weight: 700;
1455
- margin-bottom: 1.5rem;
1456
- }
1457
-
1458
- .menu-toggle {
1459
- display: none;
1460
- margin-bottom: 1rem;
1461
- }
1462
-
1463
- nav {
1464
- display: grid;
1465
- gap: 0.6rem;
1466
- }
1467
-
1468
- .nav-link {
1469
- color: inherit;
1470
- text-decoration: none;
1471
- padding: 0.8rem 0.95rem;
1472
- border-radius: 999px;
1473
- transition: transform 0.25s ease, background-color 0.25s ease;
1474
- }
1475
-
1476
- .nav-link:hover,
1477
- .nav-link.active {
1478
- background: rgba(255, 255, 255, 0.12);
1479
- transform: translateX(4px);
1480
- }
1481
-
1482
- .content {
1483
- padding: 2rem;
1484
- }
1485
-
1486
- .hero-card,
1487
- .panel,
1488
- .stat-card,
1489
- .login-card,
1490
- dialog {
1491
- background: var(--panel);
1492
- backdrop-filter: blur(16px);
1493
- border: 1px solid var(--line);
1494
- box-shadow: var(--shadow);
1495
- }
1496
-
1497
- .hero-card {
1498
- display: grid;
1499
- grid-template-columns: 1.3fr 0.9fr;
1500
- gap: 1.5rem;
1501
- border-radius: 32px;
1502
- padding: 2rem;
1503
- margin-bottom: 1.5rem;
1504
- }
1505
-
1506
- .eyebrow {
1507
- text-transform: uppercase;
1508
- letter-spacing: 0.14em;
1509
- color: var(--accent-strong);
1510
- font-size: 0.78rem;
1511
- }
1512
-
1513
- .hero-card h1,
1514
- .panel h2,
1515
- .login-card h2 {
1516
- margin: 0 0 0.75rem;
1517
- }
1518
-
1519
- .login-card,
1520
- .panel,
1521
- .stat-card {
1522
- border-radius: 24px;
1523
- }
1524
-
1525
- .login-card,
1526
- .settings-form,
1527
- .modal-form {
1528
- display: grid;
1529
- gap: 0.85rem;
1530
- }
1531
-
1532
- .stats-grid,
1533
- .workspace-grid {
1534
- display: grid;
1535
- gap: 1rem;
1536
- }
1537
-
1538
- .stats-grid {
1539
- grid-template-columns: repeat(4, minmax(0, 1fr));
1540
- margin-bottom: 1rem;
1541
- }
1542
-
1543
- .workspace-grid {
1544
- grid-template-columns: repeat(2, minmax(0, 1fr));
1545
- }
1546
-
1547
- .stat-card,
1548
- .panel {
1549
- padding: 1.2rem;
1550
- animation: riseIn 0.7s ease forwards;
1551
- }
1552
-
1553
- .stat-card span,
1554
- .panel-header span,
1555
- .activity-feed span,
1556
- .team-list span,
1557
- .billing-card p {
1558
- color: var(--muted);
1559
- }
1560
-
1561
- .panel-header {
1562
- display: flex;
1563
- align-items: center;
1564
- justify-content: space-between;
1565
- gap: 1rem;
1566
- margin-bottom: 1rem;
1567
- }
1568
-
1569
- .chart-bars {
1570
- display: grid;
1571
- grid-template-columns: repeat(5, minmax(0, 1fr));
1572
- gap: 0.9rem;
1573
- align-items: end;
1574
- min-height: 220px;
1575
- }
1576
-
1577
- .bar {
1578
- position: relative;
1579
- min-height: 180px;
1580
- border-radius: 20px 20px 8px 8px;
1581
- background: linear-gradient(180deg, rgba(182, 84, 44, 0.92), rgba(127, 52, 23, 0.68));
1582
- transform-origin: bottom;
1583
- transform: scaleY(calc(var(--value) / 100));
1584
- transition: transform 0.6s ease;
1585
- }
1586
-
1587
- .bar span {
1588
- position: absolute;
1589
- left: 50%;
1590
- bottom: -1.6rem;
1591
- transform: translateX(-50%);
1592
- }
1593
-
1594
- .activity-feed,
1595
- .team-list {
1596
- display: grid;
1597
- gap: 0.8rem;
1598
- padding: 0;
1599
- margin: 0;
1600
- list-style: none;
1601
- }
1602
-
1603
- .activity-feed li,
1604
- .team-list div,
1605
- .billing-card {
1606
- padding: 0.9rem 1rem;
1607
- border-radius: 18px;
1608
- background: rgba(255, 255, 255, 0.7);
1609
- border: 1px solid var(--line);
1610
- }
1611
-
1612
- label {
1613
- display: grid;
1614
- gap: 0.35rem;
1615
- font-size: 0.95rem;
1616
- }
1617
-
1618
- input,
1619
- select,
1620
- button {
1621
- font: inherit;
1622
- }
1623
-
1624
- input,
1625
- select {
1626
- width: 100%;
1627
- padding: 0.85rem 1rem;
1628
- border-radius: 14px;
1629
- border: 1px solid var(--line);
1630
- background: rgba(255, 255, 255, 0.92);
1631
- }
1632
-
1633
- button {
1634
- border: none;
1635
- border-radius: 999px;
1636
- padding: 0.85rem 1.2rem;
1637
- background: var(--accent);
1638
- color: #fff9f3;
1639
- cursor: pointer;
1640
- transition: transform 0.25s ease, background-color 0.25s ease;
1641
- }
1642
-
1643
- button:hover {
1644
- background: var(--accent-strong);
1645
- transform: translateY(-2px);
1646
- }
1647
-
1648
- .secondary-action,
1649
- menu button:first-child {
1650
- background: rgba(24, 34, 47, 0.12);
1651
- color: var(--ink);
1652
- }
1653
-
1654
- dialog {
1655
- border-radius: 28px;
1656
- padding: 0;
1657
- width: min(420px, calc(100% - 2rem));
1658
- }
1659
-
1660
- dialog::backdrop {
1661
- background: rgba(24, 34, 47, 0.3);
1662
- }
1663
-
1664
- .modal-form {
1665
- padding: 1.4rem;
1666
- }
1667
-
1668
- menu {
1669
- display: flex;
1670
- justify-content: flex-end;
1671
- gap: 0.75rem;
1672
- padding: 0;
1673
- margin: 0.5rem 0 0;
1674
- }
1675
-
1676
- @keyframes riseIn {
1677
- from {
1678
- opacity: 0;
1679
- transform: translateY(18px);
1680
- }
1681
- to {
1682
- opacity: 1;
1683
- transform: translateY(0);
1684
- }
1685
- }
1686
-
1687
- @media (max-width: 980px) {
1688
- .app-shell,
1689
- .hero-card,
1690
- .stats-grid,
1691
- .workspace-grid {
1692
- grid-template-columns: 1fr;
1693
- }
1694
-
1695
- .sidebar {
1696
- position: static;
1697
- min-height: auto;
1698
- }
1699
-
1700
- .menu-toggle {
1701
- display: inline-flex;
1702
- }
1703
-
1704
- nav {
1705
- display: none;
1706
- }
1707
-
1708
- nav.is-open {
1709
- display: grid;
1710
- }
1711
- }
1712
- `;
1713
- const js = `document.addEventListener('DOMContentLoaded', () => {
1714
- const menuToggle = document.getElementById('menu-toggle');
1715
- const nav = document.querySelector('nav');
1716
- const modal = document.getElementById('campaign-modal');
1717
- const openModal = document.getElementById('open-modal');
1718
- const navLinks = document.querySelectorAll('.nav-link');
1719
-
1720
- menuToggle?.addEventListener('click', () => nav?.classList.toggle('is-open'));
1721
- openModal?.addEventListener('click', () => modal?.showModal());
1722
- modal?.addEventListener('close', () => document.body.classList.remove('modal-open'));
1723
-
1724
- navLinks.forEach((link) => {
1725
- link.addEventListener('click', (event) => {
1726
- event.preventDefault();
1727
- navLinks.forEach((entry) => entry.classList.remove('active'));
1728
- link.classList.add('active');
1729
- document.querySelector(link.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth', block: 'start' });
1730
- nav?.classList.remove('is-open');
1731
- });
1732
- });
1733
-
1734
- document.querySelectorAll('.bar').forEach((bar, index) => {
1735
- bar.animate([
1736
- { transform: 'scaleY(0.15)' },
1737
- { transform: getComputedStyle(bar).transform || 'scaleY(1)' }
1738
- ], { duration: 600 + index * 80, fill: 'forwards', easing: 'ease-out' });
1739
- });
1740
- });
1741
- `;
1742
- fs_1.default.writeFileSync(path_1.default.join(rootPath, 'index.html'), `${html.trimEnd()}\n`, 'utf8');
1743
- fs_1.default.writeFileSync(path_1.default.join(rootPath, 'styles.css'), `${css.trimEnd()}\n`, 'utf8');
1744
- fs_1.default.writeFileSync(path_1.default.join(rootPath, 'scripts.js'), `${js.trimEnd()}\n`, 'utf8');
1745
- return appName;
1746
- }
1747
1649
  ensureExecutionContext(context = {}) {
1748
1650
  const existingId = String(context.contextId || context.traceId || '').trim();
1749
1651
  const contextId = existingId || `vig-${Date.now()}-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
@@ -1796,7 +1698,7 @@ menu {
1796
1698
  });
1797
1699
  if (!response.ok) {
1798
1700
  const errorText = await response.text().catch(() => '');
1799
- throw new Error(`MCP context update ${response.status}: ${describeUpstreamStatus(response.status)}`);
1701
+ throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1800
1702
  }
1801
1703
  return {
1802
1704
  ...executionContext,
@@ -1827,7 +1729,7 @@ menu {
1827
1729
  });
1828
1730
  if (!createResponse.ok) {
1829
1731
  const errorText = await createResponse.text().catch(() => '');
1830
- throw new Error(`MCP context create ${createResponse.status}: ${describeUpstreamStatus(createResponse.status)}`);
1732
+ throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText)}`);
1831
1733
  }
1832
1734
  const payload = await createResponse.json();
1833
1735
  const mcpContextId = String(payload.contextId || '').trim();
@@ -1917,6 +1819,14 @@ menu {
1917
1819
  * Budget: up to ~2 MB total, per-file cap 200 KB, skip binary extensions.
1918
1820
  */
1919
1821
  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
+ }
1920
1830
  const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
1921
1831
  const MAX_FILE_BYTES = 200 * 1024;
1922
1832
  const BINARY_EXTENSIONS = new Set([
@@ -2184,11 +2094,6 @@ menu {
2184
2094
  if (!looksLikeFrontendTask) {
2185
2095
  return;
2186
2096
  }
2187
- // Skip motion/scroll enhancements for games — they use canvas, not section-based layouts
2188
- const looksLikeGame = /\bgame\b|arcade|pac.?man|tetris|platformer|roguelike|breakout|pong|snake\s+game|tower\s+defense|playable/i.test(prompt);
2189
- if (looksLikeGame) {
2190
- return;
2191
- }
2192
2097
  const htmlPath = path_1.default.join(rootPath, 'index.html');
2193
2098
  if (!fs_1.default.existsSync(htmlPath)) {
2194
2099
  return;
@@ -2635,7 +2540,7 @@ document.addEventListener('DOMContentLoaded', () => {
2635
2540
  let contextId = response.headers.get('x-context-id') || String(context.contextId || '').trim() || null;
2636
2541
  let serverWorkspaceRoot = null;
2637
2542
  const streamedFiles = {};
2638
- const idleTimeoutMs = context.agentIdleTimeoutMs ?? DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS;
2543
+ const idleTimeoutMs = context.agentIdleTimeoutMs || DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS;
2639
2544
  while (true) {
2640
2545
  let chunk;
2641
2546
  try {
@@ -2704,19 +2609,6 @@ document.addEventListener('DOMContentLoaded', () => {
2704
2609
  serverWorkspaceRoot = event.workspace_root.trim();
2705
2610
  }
2706
2611
  this.captureV3AgentStreamMutation(event, streamedFiles, serverWorkspaceRoot);
2707
- // Real-time workspace streaming: apply file mutations to local disk immediately
2708
- if (event.type === 'file_mutation') {
2709
- const localRoot = context.projectPath || context.workspacePath || context.targetPath;
2710
- if (localRoot && typeof event.path === 'string') {
2711
- (0, workspace_stream_js_1.applyFileMutation)(event, localRoot);
2712
- if (typeof event.content === 'string') {
2713
- const relPath = this.normalizeAgentWorkspaceRelativePath(event.path, serverWorkspaceRoot || undefined);
2714
- if (relPath) {
2715
- streamedFiles[relPath] = event.content;
2716
- }
2717
- }
2718
- }
2719
- }
2720
2612
  // Empty workspace guard: if the remote agent lists its root
2721
2613
  // and finds nothing while our local workspace has files, the
2722
2614
  // workspace was not hydrated. Abort early with a clear error
@@ -2773,7 +2665,7 @@ document.addEventListener('DOMContentLoaded', () => {
2773
2665
  partial: true,
2774
2666
  };
2775
2667
  }
2776
- throw new Error(`V3 agent: ${sanitizeUserFacingErrorText(event.message || '') || 'returned an error'}`);
2668
+ throw new Error(event.message || 'V3 agent returned an error');
2777
2669
  }
2778
2670
  if (event.type === 'complete' || event.type === 'message') {
2779
2671
  final = event;
@@ -2791,13 +2683,16 @@ document.addEventListener('DOMContentLoaded', () => {
2791
2683
  }
2792
2684
  async runV3AgentWorkflow(message, context = {}) {
2793
2685
  const executionContext = await this.bindExecutionContext(context);
2794
- const baseTimeoutMs = executionContext.agentTimeoutMs ?? DEFAULT_V3_AGENT_TIMEOUT_MS;
2686
+ const baseTimeoutMs = executionContext.agentTimeoutMs || DEFAULT_V3_AGENT_TIMEOUT_MS;
2795
2687
  const expectedFiles = this.extractExpectedWorkspaceFiles(message, executionContext);
2796
2688
  const requestedModel = String(executionContext.model || executionContext.requestedModel || 'agent');
2797
2689
  const resolvedModel = this.resolvePermittedModelId(requestedModel);
2798
2690
  const preferLocalV3 = /(premium|polished|landing|site|page|dashboard|saas|frontend|ui|responsive|animated|create the required project files and write them to the workspace)/i.test(message)
2799
2691
  && context.localMachineCapable !== false;
2800
- const timeoutMs = baseTimeoutMs;
2692
+ const rescueEligibleSaaS = preferLocalV3
2693
+ && /(saas|dashboard|analytics|billing|team management|activity feed|login screen)/i.test(message);
2694
+ const timeoutMs = rescueEligibleSaaS ? Math.min(baseTimeoutMs, 210000) : baseTimeoutMs;
2695
+ const softTimeoutMs = executionContext.agentSoftTimeoutMs || DEFAULT_V3_AGENT_SOFT_TIMEOUT_MS;
2801
2696
  const maxAttempts = preferLocalV3 ? 2 : 1;
2802
2697
  let lastErrors = [];
2803
2698
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -2831,12 +2726,14 @@ document.addEventListener('DOMContentLoaded', () => {
2831
2726
  };
2832
2727
  for (const baseUrl of this.getV3AgentBaseUrls(preferLocalV3)) {
2833
2728
  const controller = new AbortController();
2834
- const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
2729
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2730
+ const softTimeoutId = softTimeoutMs > 0 ? setTimeout(() => controller.abort(), softTimeoutMs) : null;
2835
2731
  try {
2836
2732
  const response = await this.executeV3AgentRunRequest(baseUrl, requestBody, requestExecutionContext, controller.signal);
2733
+ clearTimeout(timeoutId);
2837
2734
  if (!response.ok) {
2838
2735
  const errorText = await response.text().catch(() => '');
2839
- throw new Error(`V3 agent ${response.status}: ${describeUpstreamStatus(response.status)}`);
2736
+ throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
2840
2737
  }
2841
2738
  const data = await this.collectV3AgentStream(response, requestExecutionContext);
2842
2739
  // Auto-continuation: if the agent checkpointed (budget exceeded), continue automatically
@@ -2863,7 +2760,8 @@ document.addEventListener('DOMContentLoaded', () => {
2863
2760
  stream: true,
2864
2761
  };
2865
2762
  const continueController = new AbortController();
2866
- const continueTimeoutId = timeoutMs > 0 ? setTimeout(() => continueController.abort(), timeoutMs) : null;
2763
+ const continueTimeoutId = setTimeout(() => continueController.abort(), timeoutMs);
2764
+ const continueSoftTimeoutId = softTimeoutMs > 0 ? setTimeout(() => continueController.abort(), softTimeoutMs) : null;
2867
2765
  try {
2868
2766
  const continueHeaders = await this.getV3AgentHeaders();
2869
2767
  const continueResponse = await fetch(this.getV3AgentContinueUrl(baseUrl), {
@@ -2872,6 +2770,7 @@ document.addEventListener('DOMContentLoaded', () => {
2872
2770
  body: JSON.stringify(continueBody),
2873
2771
  signal: continueController.signal,
2874
2772
  });
2773
+ clearTimeout(continueTimeoutId);
2875
2774
  if (!continueResponse.ok) {
2876
2775
  break; // Fall through to normal completion with partial data
2877
2776
  }
@@ -2881,8 +2780,9 @@ document.addEventListener('DOMContentLoaded', () => {
2881
2780
  break; // Fall through to normal completion with partial data
2882
2781
  }
2883
2782
  finally {
2884
- if (continueTimeoutId) {
2885
- clearTimeout(continueTimeoutId);
2783
+ clearTimeout(continueTimeoutId);
2784
+ if (continueSoftTimeoutId) {
2785
+ clearTimeout(continueSoftTimeoutId);
2886
2786
  }
2887
2787
  }
2888
2788
  }
@@ -2898,7 +2798,6 @@ document.addEventListener('DOMContentLoaded', () => {
2898
2798
  contextId: finalContextId,
2899
2799
  backendUrl: baseUrl,
2900
2800
  partial: continuationData.checkpointed === true,
2901
- changedFiles: continuationData.files || data.files || {},
2902
2801
  metadata: { source: 'v3-agent', mode: 'agent', contextId: finalContextId, continuations, previewGate },
2903
2802
  };
2904
2803
  }
@@ -2917,7 +2816,6 @@ document.addEventListener('DOMContentLoaded', () => {
2917
2816
  taskId: data.task_id || null,
2918
2817
  contextId,
2919
2818
  backendUrl: baseUrl,
2920
- changedFiles: data.files || {},
2921
2819
  metadata: { source: 'v3-agent', mode: 'agent', contextId, mcpContextId, previewGate },
2922
2820
  };
2923
2821
  }
@@ -2933,15 +2831,15 @@ document.addEventListener('DOMContentLoaded', () => {
2933
2831
  contextId: error.partialData.context_id || requestExecutionContext.contextId || executionContext.contextId || null,
2934
2832
  backendUrl: baseUrl,
2935
2833
  partial: true,
2936
- changedFiles: error.partialData.files || {},
2937
2834
  metadata: { source: 'v3-agent', mode: 'agent', partial: true, contextId: error.partialData.context_id || requestExecutionContext.contextId || executionContext.contextId || null, mcpContextId: requestExecutionContext.mcpContextId || executionContext.mcpContextId || null, previewGate },
2938
2835
  };
2939
2836
  }
2940
2837
  errors.push(`${baseUrl}: ${error?.message || String(error)}`);
2941
2838
  }
2942
2839
  finally {
2943
- if (timeoutId) {
2944
- clearTimeout(timeoutId);
2840
+ clearTimeout(timeoutId);
2841
+ if (softTimeoutId) {
2842
+ clearTimeout(softTimeoutId);
2945
2843
  }
2946
2844
  }
2947
2845
  }
@@ -2964,24 +2862,6 @@ document.addEventListener('DOMContentLoaded', () => {
2964
2862
  this.config.clearAuth();
2965
2863
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
2966
2864
  }
2967
- if (preferLocalV3
2968
- && !this.hasAgentWorkspaceOutput(executionContext)
2969
- && /(saas|dashboard|analytics|billing|team management|activity feed)/i.test(message)) {
2970
- const appName = this.materializeEmergencySaaSWorkspace(message, executionContext);
2971
- if (appName) {
2972
- await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles: ['index.html', 'styles.css', 'scripts.js'] });
2973
- await this.ensureAgentFrontendPolish(message, executionContext);
2974
- const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
2975
- return {
2976
- content: `Recovered a local SaaS workspace scaffold for ${appName} after repeated V3 materialization failures.`,
2977
- taskId: null,
2978
- contextId: executionContext.contextId || null,
2979
- backendUrl: 'local-emergency-scaffold',
2980
- partial: true,
2981
- metadata: { source: 'v3-agent-emergency-scaffold', mode: 'agent', previewGate, emergencyScaffold: true },
2982
- };
2983
- }
2984
- }
2985
2865
  throw new Error(errors.join(' | '));
2986
2866
  }
2987
2867
  formatOperatorResponse(data = {}) {
@@ -3009,7 +2889,7 @@ document.addEventListener('DOMContentLoaded', () => {
3009
2889
  }
3010
2890
  async runOperatorWorkflow(message, context = {}) {
3011
2891
  const executionContext = await this.bindExecutionContext(context);
3012
- const timeoutMs = context.operatorTimeoutMs ?? DEFAULT_OPERATOR_TIMEOUT_MS;
2892
+ const timeoutMs = context.operatorTimeoutMs || DEFAULT_OPERATOR_TIMEOUT_MS;
3013
2893
  const errors = [];
3014
2894
  const authToken = this.config.get('authToken');
3015
2895
  // Collect a lightweight workspace file listing so the operator can
@@ -3018,7 +2898,7 @@ document.addEventListener('DOMContentLoaded', () => {
3018
2898
  const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
3019
2899
  for (const baseUrl of this.getOperatorBaseUrls()) {
3020
2900
  const controller = new AbortController();
3021
- const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
2901
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
3022
2902
  try {
3023
2903
  const response = await fetch(this.getOperatorStreamUrl(baseUrl), {
3024
2904
  method: 'POST',
@@ -3042,7 +2922,7 @@ document.addEventListener('DOMContentLoaded', () => {
3042
2922
  workspace: { path: workspacePath },
3043
2923
  workspace_path: workspacePath,
3044
2924
  workspace_summary: workspaceSummary,
3045
- model: this.resolveModelId(executionContext.model || 'code'),
2925
+ model: this.resolveModelId(executionContext.model || 'code-9b'),
3046
2926
  history: executionContext.history || [],
3047
2927
  executionSurface: executionContext.executionSurface || 'cli',
3048
2928
  clientSurface: executionContext.clientSurface || 'cli',
@@ -3053,7 +2933,7 @@ document.addEventListener('DOMContentLoaded', () => {
3053
2933
  rawPrompt: executionContext.rawPrompt || null,
3054
2934
  requestStartedAt: executionContext.requestStartedAt,
3055
2935
  },
3056
- workflow_type: executionContext.workflowType || 'full',
2936
+ workflow_type: executionContext.workflowType || 'analysis_only',
3057
2937
  options: {
3058
2938
  stream: true,
3059
2939
  save_to_vigflow: executionContext.savePlanToVigFlow === true,
@@ -3063,7 +2943,7 @@ document.addEventListener('DOMContentLoaded', () => {
3063
2943
  });
3064
2944
  if (!response.ok) {
3065
2945
  const errorText = await response.text().catch(() => '');
3066
- throw new Error(`Operator stream ${response.status}: ${describeUpstreamStatus(response.status)}`);
2946
+ throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText)}`);
3067
2947
  }
3068
2948
  if (!response.body || typeof response.body.getReader !== 'function') {
3069
2949
  const fallbackData = await response.json();
@@ -3165,9 +3045,7 @@ document.addEventListener('DOMContentLoaded', () => {
3165
3045
  errors.push(`${baseUrl}: ${error?.message || String(error)}`);
3166
3046
  }
3167
3047
  finally {
3168
- if (timeoutId) {
3169
- clearTimeout(timeoutId);
3170
- }
3048
+ clearTimeout(timeoutId);
3171
3049
  }
3172
3050
  }
3173
3051
  throw new CLIError(`Operator workflow failed on all endpoints: ${errors.join(' | ')}`, 'model_backend');
@@ -3219,7 +3097,7 @@ document.addEventListener('DOMContentLoaded', () => {
3219
3097
  if (!this.shouldSkipCloudRoutes(resolvedModel)) {
3220
3098
  try {
3221
3099
  this.logger.debug(`Direct Vigthoria Models API: ${resolvedModel}`);
3222
- const token = this.config.get('authToken');
3100
+ const token = this.getAccessToken();
3223
3101
  const response = await this.modelRouterClient.post('/v1/chat/completions', {
3224
3102
  model: resolvedModel,
3225
3103
  messages,
@@ -3333,20 +3211,18 @@ document.addEventListener('DOMContentLoaded', () => {
3333
3211
  }
3334
3212
  getFallbackModelId(resolvedModel) {
3335
3213
  const cloudModels = new Set([
3336
- 'deepseek-v3.1:671b-cloud',
3337
3214
  'moonshotai/kimi-k2.5',
3338
3215
  'vigthoria-cloud-pro',
3339
3216
  'vigthoria-cloud-k2',
3340
3217
  'vigthoria-cloud-ultra',
3341
3218
  ]);
3342
3219
  if (cloudModels.has(resolvedModel)) {
3343
- return 'vigthoria-v3-code-30b';
3220
+ return 'vigthoria-v3-code-35b';
3344
3221
  }
3345
3222
  return null;
3346
3223
  }
3347
3224
  isCloudModelId(resolvedModel) {
3348
- return resolvedModel === 'deepseek-v3.1:671b-cloud'
3349
- || resolvedModel === 'moonshotai/kimi-k2.5'
3225
+ return resolvedModel === 'moonshotai/kimi-k2.5'
3350
3226
  || resolvedModel === 'vigthoria-cloud-pro'
3351
3227
  || resolvedModel === 'vigthoria-cloud-k2'
3352
3228
  || resolvedModel === 'vigthoria-cloud-ultra';
@@ -3356,11 +3232,25 @@ document.addEventListener('DOMContentLoaded', () => {
3356
3232
  }
3357
3233
  resolvePermittedModelId(shortName) {
3358
3234
  const resolvedModel = this.resolveModelId(shortName);
3235
+ const requested = String(shortName || '').toLowerCase();
3359
3236
  if (this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()) {
3360
3237
  const fallbackModel = this.getSelfHostedFallbackModelId(resolvedModel, shortName);
3361
3238
  this.logger.debug(`Blocked unauthorized cloud model ${shortName}; using fallback ${fallbackModel}`);
3362
3239
  return fallbackModel;
3363
3240
  }
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
+ }
3364
3254
  return resolvedModel;
3365
3255
  }
3366
3256
  shouldSimulateCloudFailure() {
@@ -3379,27 +3269,29 @@ document.addEventListener('DOMContentLoaded', () => {
3379
3269
  isSelfHostedPreferredModel(resolvedModel, requestedModel) {
3380
3270
  const normalizedRequested = String(requestedModel || '').toLowerCase();
3381
3271
  const selfHostedModels = new Set([
3382
- 'vigthoria-v3-code-30b',
3383
- 'vigthoria-v3-code-30b:latest',
3272
+ 'vigthoria-v3-code-35b',
3273
+ 'vigthoria-v3-code-35b:latest',
3384
3274
  'qwen3-coder:latest',
3385
- 'vigthoria-v2-code-8b',
3275
+ 'vigthoria_c1_m',
3386
3276
  ]);
3387
3277
  return selfHostedModels.has(resolvedModel)
3388
3278
  || normalizedRequested === 'agent'
3389
3279
  || normalizedRequested === 'code'
3390
- || normalizedRequested === 'code-30b'
3280
+ || normalizedRequested === 'code-35b'
3281
+ || normalizedRequested === 'code-35b'
3282
+ || normalizedRequested === 'code-9b'
3391
3283
  || normalizedRequested === 'pro';
3392
3284
  }
3393
3285
  getSelfHostedFallbackModelId(resolvedModel, requestedModel) {
3394
3286
  if (this.isSelfHostedPreferredModel(resolvedModel, requestedModel)) {
3395
- return resolvedModel === 'qwen3-coder:latest' ? 'vigthoria-v3-code-30b' : resolvedModel;
3287
+ return resolvedModel === 'qwen3-coder:latest' ? 'vigthoria-v3-code-35b' : resolvedModel;
3396
3288
  }
3397
- return 'vigthoria-v3-code-30b';
3289
+ return 'vigthoria-v3-code-35b';
3398
3290
  }
3399
3291
  // Streaming chat
3400
3292
  async *chatStream(messages, model) {
3401
3293
  const wsUrl = this.config.get('wsUrl');
3402
- const token = this.config.get('authToken');
3294
+ const token = this.getAccessToken();
3403
3295
  return new Promise((resolve, reject) => {
3404
3296
  const ws = new ws_1.default(`${wsUrl}/chat`, {
3405
3297
  headers: { Authorization: `Bearer ${token}` },
@@ -3428,7 +3320,7 @@ document.addEventListener('DOMContentLoaded', () => {
3428
3320
  // Non-streaming alternative with callback
3429
3321
  async chatWithCallback(messages, model, onChunk, onDone, onError) {
3430
3322
  const wsUrl = this.config.get('wsUrl');
3431
- const token = this.config.get('authToken');
3323
+ const token = this.getAccessToken();
3432
3324
  return new Promise((resolve, reject) => {
3433
3325
  const ws = new ws_1.default(`${wsUrl}/chat`, {
3434
3326
  headers: { Authorization: `Bearer ${token}` },
@@ -3477,7 +3369,7 @@ document.addEventListener('DOMContentLoaded', () => {
3477
3369
  // (/v1/chat/completions on api.vigthoria.io) which is the only
3478
3370
  // backend that reliably accepts our auth token.
3479
3371
  async chatComplete(systemPrompt, userPrompt, model, maxTokens) {
3480
- const resolvedModel = model ? this.resolvePermittedModelId(model) : 'vigthoria-v3-code-30b';
3372
+ const resolvedModel = model ? this.resolvePermittedModelId(model) : 'vigthoria-v3-code-35b';
3481
3373
  const response = await this.modelRouterClient.post('/v1/chat/completions', {
3482
3374
  model: resolvedModel,
3483
3375
  messages: [
@@ -3557,76 +3449,21 @@ document.addEventListener('DOMContentLoaded', () => {
3557
3449
  * Ensure code has balanced curly braces by appending missing closing braces.
3558
3450
  */
3559
3451
  ensureBalancedBraces(code) {
3560
- // Count braces/parens/brackets outside strings and comments
3561
- let braces = 0, parens = 0, brackets = 0;
3562
- let inStr = null;
3563
- let inLine = false, inBlock = false;
3564
- for (let i = 0; i < code.length; i++) {
3565
- const ch = code[i], nx = code[i + 1] || '';
3566
- if (inLine) {
3567
- if (ch === '\n')
3568
- inLine = false;
3569
- continue;
3570
- }
3571
- if (inBlock) {
3572
- if (ch === '*' && nx === '/') {
3573
- inBlock = false;
3574
- i++;
3575
- }
3576
- continue;
3577
- }
3578
- if (inStr) {
3579
- if (ch === inStr && code[i - 1] !== '\\')
3580
- inStr = null;
3581
- continue;
3582
- }
3583
- if (ch === '/' && nx === '/') {
3584
- inLine = true;
3585
- continue;
3586
- }
3587
- if (ch === '/' && nx === '*') {
3588
- inBlock = true;
3589
- continue;
3590
- }
3591
- if (ch === '"' || ch === "'" || ch === '`') {
3592
- inStr = ch;
3593
- continue;
3594
- }
3452
+ let depth = 0;
3453
+ for (const ch of code) {
3595
3454
  if (ch === '{')
3596
- braces++;
3455
+ depth++;
3597
3456
  else if (ch === '}')
3598
- braces--;
3599
- else if (ch === '(')
3600
- parens++;
3601
- else if (ch === ')')
3602
- parens--;
3603
- else if (ch === '[')
3604
- brackets++;
3605
- else if (ch === ']')
3606
- brackets--;
3607
- }
3608
- let result = code.trimEnd();
3609
- for (let i = 0; i < braces; i++)
3610
- result += '\n}';
3611
- for (let i = 0; i < parens; i++)
3612
- result += ')';
3613
- for (let i = 0; i < brackets; i++)
3614
- result += ']';
3615
- return braces > 0 || parens > 0 || brackets > 0 ? result : code;
3616
- }
3617
- /**
3618
- * Quick JS/TS syntax validation using Node's built-in parser.
3619
- * Returns true if the code parses without errors.
3620
- */
3621
- validateJsSyntax(code) {
3622
- try {
3623
- // Use Function constructor to check syntax without executing
3624
- new Function(code);
3625
- return true;
3457
+ depth--;
3626
3458
  }
3627
- catch {
3628
- return false;
3459
+ if (depth > 0) {
3460
+ let result = code.trimEnd();
3461
+ for (let i = 0; i < depth; i++) {
3462
+ result += '\n}';
3463
+ }
3464
+ code = result;
3629
3465
  }
3466
+ return code;
3630
3467
  }
3631
3468
  /**
3632
3469
  * Extract the first complete function/class from code.
@@ -3734,17 +3571,7 @@ document.addEventListener('DOMContentLoaded', () => {
3734
3571
  }
3735
3572
  }
3736
3573
  async explainCode(code, language) {
3737
- const sysPrompt = [
3738
- `You are a code explainer. Explain the following ${language} code clearly and concisely.`,
3739
- 'Focus on what it does, how it works, and any notable patterns or potential issues.',
3740
- 'Format your response as clean Markdown:',
3741
- '- Use ## headers for major sections (e.g. ## Overview, ## How It Works, ## Key Details).',
3742
- '- Use bullet points (- or *) for all lists. Do NOT use numbered lists.',
3743
- '- Wrap code references in backticks.',
3744
- '- Keep paragraphs short (2-3 sentences max).',
3745
- '- Do NOT use raw HTML or excessive blank lines.',
3746
- '- Do NOT nest numbered lists inside sections.',
3747
- ].join('\n');
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.`;
3748
3575
  return this.chatComplete(sysPrompt, code);
3749
3576
  }
3750
3577
  async reviewCode(code, language) {
@@ -3756,11 +3583,9 @@ document.addEventListener('DOMContentLoaded', () => {
3756
3583
  'Rules:',
3757
3584
  '- Return concrete, line-specific issues with severity.',
3758
3585
  '- Every issue MUST reference a line number.',
3759
- '- Report each distinct bug ONCE. Do NOT report the same bug multiple times with different wording.',
3760
- '- For trivial/short code (< 10 lines), report ONLY actual bugs. Do NOT pad with style, robustness, or best-practice suggestions.',
3761
- '- 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.',
3586
+ '- If the score is below 50, list at least 2 specific issues.',
3762
3587
  '- Prioritize REAL BUGS: wrong operators, logic errors, off-by-one, type mismatches.',
3763
- '- Do NOT suggest adding error handling, input validation, or documentation as issues unless the user explicitly asked for a style review.',
3588
+ '- Only report style issues AFTER listing all real bugs.',
3764
3589
  '- Return ONLY the JSON object, no markdown fences or extra text.',
3765
3590
  ].join('\n');
3766
3591
  let raw = {};
@@ -3775,40 +3600,25 @@ document.addEventListener('DOMContentLoaded', () => {
3775
3600
  const score = typeof raw.score === 'number' ? raw.score : 0;
3776
3601
  const issues = Array.isArray(raw.issues) ? raw.issues : [];
3777
3602
  const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
3778
- // Merge client-side heuristics, but with tight dedup to avoid
3779
- // redundant over-reporting when the model already found the bug.
3780
- const modelFoundError = issues.some(i => i.severity === 'error');
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.
3781
3606
  const heuristic = this.heuristicCodeIssues(code, language);
3782
3607
  for (const h of heuristic) {
3783
- // If the model already found a real error, skip non-error heuristics
3784
- // entirelythey're just padding (style, robustness, etc.)
3785
- if (modelFoundError && h.severity !== 'error')
3786
- continue;
3787
- // Semantic duplicate check: same line + (similar type OR overlapping
3788
- // keywords in the message). This catches cases where the model
3789
- // and heuristic describe the same bug with different wording.
3790
- const hWords = new Set(h.message.toLowerCase().split(/\W+/).filter(w => w.length > 3));
3791
- const hTypeNorm = h.type.toLowerCase().replace(/[^a-z]/g, '');
3792
- const isSemanticallyDuplicate = issues.some((existing) => {
3793
- if (existing.line !== h.line)
3794
- return false;
3795
- // Normalize types: "logic-error", "logic_error", "logic" all match
3796
- const eTypeNorm = existing.type.toLowerCase().replace(/[^a-z]/g, '');
3797
- if (eTypeNorm === hTypeNorm || eTypeNorm.startsWith(hTypeNorm) || hTypeNorm.startsWith(eTypeNorm))
3798
- return true;
3799
- // Both errors on same line about the same category of problem
3800
- if (existing.severity === 'error' && h.severity === 'error')
3801
- return true;
3802
- // Check keyword overlap — if ≥2 significant words match, it's the same finding
3803
- const eWords = existing.message.toLowerCase().split(/\W+/).filter(w => w.length > 3);
3804
- let overlap = 0;
3805
- for (const w of eWords) {
3806
- if (hWords.has(w))
3807
- overlap++;
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);
3808
3615
  }
3809
- return overlap >= 2;
3810
- });
3811
- if (!isSemanticallyDuplicate) {
3616
+ 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) {
3812
3622
  issues.push(h);
3813
3623
  }
3814
3624
  }
@@ -3957,11 +3767,9 @@ document.addEventListener('DOMContentLoaded', () => {
3957
3767
  const sysPrompt = [
3958
3768
  `You are a ${language} code fixer. Fix the code for: ${fixType}.`,
3959
3769
  'Return a JSON object with:',
3960
- ' "fixed": the COMPLETE corrected source code as a string (not a snippet — the full file),',
3770
+ ' "fixed": the corrected code as a string,',
3961
3771
  ' "changes": [{ "line": number, "before": string, "after": string, "reason": string }]',
3962
3772
  'Rules:',
3963
- '- The "fixed" field MUST contain the entire corrected source code with ALL lines, including unchanged lines.',
3964
- '- The "fixed" code MUST have balanced braces, parentheses, and brackets.',
3965
3773
  '- Fix ONLY the issues related to the fix type.',
3966
3774
  '- Do not add comments, do not restructure beyond the minimal fix.',
3967
3775
  '- Return ONLY the JSON object, no markdown fences.',
@@ -4001,26 +3809,6 @@ document.addEventListener('DOMContentLoaded', () => {
4001
3809
  if (fixType === 'syntax' && fixed !== code) {
4002
3810
  fixed = this.repairBracketBalance(code, fixed);
4003
3811
  }
4004
- // Final bracket-balance guarantee — ensure the emitted code has
4005
- // balanced braces/parens/brackets regardless of what the model returned.
4006
- fixed = this.ensureBalancedBraces(fixed);
4007
- // For JS/TS syntax fixes, validate the output actually parses.
4008
- // If it doesn't, attempt a more aggressive bracket repair.
4009
- if ((fixType === 'syntax' || fixType === 'bugs') && fixed !== code) {
4010
- const lang = language.toLowerCase();
4011
- if (['javascript', 'js', 'typescript', 'ts'].includes(lang)) {
4012
- if (!this.validateJsSyntax(fixed)) {
4013
- // Try once more: strip any remaining injected comments and re-balance
4014
- let repaired = this.stripInjectedComments(code, fixed, language);
4015
- repaired = this.ensureBalancedBraces(repaired);
4016
- if (this.validateJsSyntax(repaired)) {
4017
- fixed = repaired;
4018
- }
4019
- // If still invalid, return the best-effort fix — better than
4020
- // silently reverting to the original broken code.
4021
- }
4022
- }
4023
- }
4024
3812
  // If there are still no changes but the fixed code differs, compute
4025
3813
  // a semantic diff using LCS so inserted/removed lines don't cause
4026
3814
  // every subsequent line to appear as changed.
@@ -4322,23 +4110,116 @@ document.addEventListener('DOMContentLoaded', () => {
4322
4110
  }
4323
4111
  // Model resolution - maps Vigthoria model names to internal IDs
4324
4112
  // 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
+ }
4325
4203
  resolveModelId(shortName) {
4326
4204
  const modelMap = {
4327
4205
  // ═══════════════════════════════════════════════════════════════
4328
4206
  // VIGTHORIA LOCAL - Self-hosted models
4329
4207
  // ═══════════════════════════════════════════════════════════════
4330
- 'fast': 'vigthoria-fast-1.7b',
4331
- 'mini': 'vigthoria-mini-0.6b',
4332
- 'balanced': 'vigthoria-balanced-4b',
4333
- 'creative': 'vigthoria-creative-9b-v4',
4334
- // Code Models - 30B is the default powerhouse
4335
- 'code': 'vigthoria-v3-code-30b', // Internal: self-hosted 30B on Blackwell
4336
- 'code-30b': 'vigthoria-v3-code-30b',
4337
- 'code-8b': 'vigthoria-v2-code-8b',
4338
- 'pro': 'vigthoria-v3-code-30b',
4339
- 'agent': 'vigthoria-v3-code-30b',
4340
- 'vigthoria-code': 'vigthoria-v3-code-30b',
4341
- 'vigthoria-agent': 'vigthoria-v3-code-30b',
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',
4342
4223
  // ═══════════════════════════════════════════════════════════════
4343
4224
  // VIGTHORIA CLOUD - Premium cloud models (internal routing)
4344
4225
  // ═══════════════════════════════════════════════════════════════
@@ -4346,18 +4227,17 @@ document.addEventListener('DOMContentLoaded', () => {
4346
4227
  'cloud-reason': 'vigthoria-cloud-k2',
4347
4228
  'ultra': 'vigthoria-cloud-ultra',
4348
4229
  };
4349
- // If already a full model ID, return as-is
4350
4230
  if (shortName.includes('vigthoria') || shortName.includes('/') || shortName.includes(':')) {
4351
4231
  if (modelMap[shortName]) {
4352
4232
  return modelMap[shortName];
4353
4233
  }
4354
4234
  return shortName;
4355
4235
  }
4356
- return modelMap[shortName] || 'vigthoria-v3-code-30b';
4236
+ return modelMap[shortName] || 'vigthoria-v3-code-35b';
4357
4237
  }
4358
4238
  async getCoderHealth() {
4359
4239
  try {
4360
- const response = await this.client.get('/api/health', { timeout: 5000 });
4240
+ const response = await this.client.get('/api/health', { timeout: 10000 });
4361
4241
  const ok = response.data?.status === 'ok' || response.data?.healthy === true;
4362
4242
  return {
4363
4243
  name: 'Coder API',
@@ -4379,8 +4259,8 @@ document.addEventListener('DOMContentLoaded', () => {
4379
4259
  const modelsApiUrl = this.config.get('modelsApiUrl');
4380
4260
  try {
4381
4261
  const [healthResponse, modelsResponse] = await Promise.all([
4382
- this.modelRouterClient.get('/health', { timeout: 5000 }),
4383
- this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
4262
+ this.modelRouterClient.get('/health', { timeout: 10000 }),
4263
+ this.modelRouterClient.get('/v1/models', { timeout: 15000 }),
4384
4264
  ]);
4385
4265
  const healthOk = healthResponse.data?.status === 'healthy'
4386
4266
  || healthResponse.data?.status === 'ok'
@@ -4411,7 +4291,7 @@ document.addEventListener('DOMContentLoaded', () => {
4411
4291
  return null;
4412
4292
  }
4413
4293
  try {
4414
- const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 5000 });
4294
+ const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 10000 });
4415
4295
  const ok = response.data?.status === 'healthy'
4416
4296
  || response.data?.status === 'ok'
4417
4297
  || response.data?.healthy === true;
@@ -4431,6 +4311,29 @@ document.addEventListener('DOMContentLoaded', () => {
4431
4311
  };
4432
4312
  }
4433
4313
  }
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
+ }
4434
4337
  async getV3AgentHealth() {
4435
4338
  const baseUrl = this.getV3AgentBaseUrls()[0];
4436
4339
  // Try multiple health endpoint patterns — the V3 backend may expose
@@ -4444,7 +4347,7 @@ document.addEventListener('DOMContentLoaded', () => {
4444
4347
  for (const endpoint of candidates) {
4445
4348
  try {
4446
4349
  const controller = new AbortController();
4447
- const timer = setTimeout(() => controller.abort(), 3000);
4350
+ const timer = setTimeout(() => controller.abort(), 8000);
4448
4351
  const response = await fetch(endpoint, {
4449
4352
  method: 'GET',
4450
4353
  headers,
@@ -4492,7 +4395,7 @@ document.addEventListener('DOMContentLoaded', () => {
4492
4395
  const runUrl = this.getV3AgentRunUrl(baseUrl);
4493
4396
  try {
4494
4397
  const controller = new AbortController();
4495
- const timer = setTimeout(() => controller.abort(), 2000);
4398
+ const timer = setTimeout(() => controller.abort(), 5000);
4496
4399
  const probe = await fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
4497
4400
  clearTimeout(timer);
4498
4401
  if (probe.ok || probe.status === 204 || probe.status === 405) {
@@ -4649,18 +4552,11 @@ document.addEventListener('DOMContentLoaded', () => {
4649
4552
  });
4650
4553
  }
4651
4554
  async getCapabilityTruthStatus(context = {}) {
4652
- // Wrap each probe with its own 6 s timeout so they always resolve
4653
- // before the outer 8 s race in auth.ts, producing real error messages
4654
- // (ECONNREFUSED, 404, etc.) instead of the generic "Timed out (8s)".
4655
- const withTimeout = (p, name) => Promise.race([
4656
- p,
4657
- new Promise(resolve => setTimeout(() => resolve({ name, endpoint: '', ok: false, error: 'Service not reachable (6 s timeout)' }), 6000)),
4658
- ]);
4659
4555
  const [v3Agent, hyperLoop, repoMemory, devtoolsBridge] = await Promise.all([
4660
- withTimeout(this.getV3AgentHealth(), 'V3 Agent'),
4661
- withTimeout(this.getHyperLoopHealth(), 'Hyper Loop'),
4662
- withTimeout(this.getRepoMemoryHealth(context), 'Repo Memory'),
4663
- withTimeout(this.getDevtoolsBridgeStatus(), 'DevTools Bridge'),
4556
+ this.getV3AgentHealth(),
4557
+ this.getHyperLoopHealth(),
4558
+ this.getRepoMemoryHealth(context),
4559
+ this.getDevtoolsBridgeStatus(),
4664
4560
  ]);
4665
4561
  return {
4666
4562
  overallOk: v3Agent.ok && hyperLoop.ok && repoMemory.ok,