vigthoria-cli 1.10.1 → 1.10.36

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
@@ -9,16 +9,24 @@ import https from 'https';
9
9
  import net from 'net';
10
10
  import path from 'path';
11
11
  import WebSocket from 'ws';
12
+ export const VIGTHORIA_HUB_CREDITS_URL = 'https://hub.vigthoria.io/credits';
13
+ export const VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE = 'Vigthoria Server is temporarily not available. Please try again later. If the issue persists, please contact support.';
12
14
  export class CLIError extends Error {
13
15
  category;
14
16
  statusCode;
15
17
  endpoint;
18
+ walletUrl;
19
+ topupUrl;
20
+ balance;
16
21
  constructor(message, category, opts) {
17
22
  super(message);
18
23
  this.name = 'CLIError';
19
24
  this.category = category;
20
25
  this.statusCode = opts?.statusCode;
21
26
  this.endpoint = opts?.endpoint;
27
+ this.walletUrl = opts?.walletUrl;
28
+ this.topupUrl = opts?.topupUrl;
29
+ this.balance = opts?.balance;
22
30
  if (opts?.cause)
23
31
  this.cause = opts.cause;
24
32
  }
@@ -30,13 +38,25 @@ export function classifyError(error, fallbackCategory = 'network') {
30
38
  const axErr = error;
31
39
  const status = axErr?.response?.status;
32
40
  const endpoint = axErr?.config?.url || axErr?.config?.baseURL || '';
33
- const message = axErr?.response?.data
34
- ? typeof axErr.response.data.error === 'string'
35
- ? axErr.response.data.error
36
- : typeof axErr.response.data.message === 'string'
37
- ? axErr.response.data.message
38
- : error.message
41
+ const responseData = axErr?.response?.data;
42
+ const nestedError = responseData?.error;
43
+ const message = responseData
44
+ ? typeof nestedError === 'string'
45
+ ? nestedError
46
+ : typeof responseData.message === 'string'
47
+ ? responseData.message
48
+ : typeof nestedError === 'object' && nestedError && typeof nestedError.message === 'string'
49
+ ? nestedError.message
50
+ : error.message
39
51
  : error.message || String(error);
52
+ if (status === 402 || responseData?.errorCode === 'INSUFFICIENT_CREDITS' || responseData?.errorCode === 'WALLET_REQUIRED' || responseData?.errorCode === 'CLOUD_CREDIT_REQUIRED') {
53
+ const walletUrl = String(responseData?.walletUrl || responseData?.topupUrl || VIGTHORIA_HUB_CREDITS_URL);
54
+ const balance = typeof responseData?.balance === 'number' ? responseData.balance : undefined;
55
+ const creditMessage = typeof responseData?.message === 'string'
56
+ ? responseData.message
57
+ : `Your cloud credit balance is too low. Add credits: ${walletUrl}`;
58
+ return new CLIError(creditMessage, 'credits', { statusCode: status || 402, endpoint, walletUrl, topupUrl: walletUrl, balance });
59
+ }
40
60
  if (status === 401 || status === 403) {
41
61
  // Distinguish repo/community auth from model auth
42
62
  if (/community|repo/i.test(endpoint)) {
@@ -64,7 +84,7 @@ export function formatCLIError(err) {
64
84
  case 'repo_session':
65
85
  return `${tag} Repository session expired or missing${err.statusCode ? ` (${err.statusCode})` : ''}. This does not affect AI commands. Re-authenticate repo with: vigthoria repo list`;
66
86
  case 'model_backend':
67
- return `${tag} Model backend error${err.statusCode ? ` (${err.statusCode})` : ''}: ${err.message}`;
87
+ return VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE;
68
88
  case 'bridge':
69
89
  return `${tag} Bridge connection error: ${err.message}`;
70
90
  case 'network':
@@ -75,6 +95,8 @@ export function formatCLIError(err) {
75
95
  return `${tag} Response parsing error: ${err.message}`;
76
96
  case 'tool_execution':
77
97
  return `${tag} Tool execution error: ${err.message}`;
98
+ case 'credits':
99
+ return `${tag} ${err.message}${err.walletUrl ? `\nTop up credits: ${err.walletUrl}` : ''}`;
78
100
  default:
79
101
  return `${tag} ${err.message}`;
80
102
  }
@@ -242,6 +264,21 @@ const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
242
264
  const parsed = Number.parseInt(rawValue, 10);
243
265
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
244
266
  })();
267
+ const DEFAULT_MCP_BIND_TIMEOUT_MS = (() => {
268
+ const rawValue = process.env.VIGTHORIA_MCP_BIND_TIMEOUT_MS || process.env.MCP_BIND_TIMEOUT_MS || '5000';
269
+ const parsed = Number.parseInt(rawValue, 10);
270
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 5000;
271
+ })();
272
+ const DEFAULT_WORKSPACE_SCAN_TIMEOUT_MS = (() => {
273
+ const rawValue = process.env.VIGTHORIA_WORKSPACE_SCAN_TIMEOUT_MS || '2500';
274
+ const parsed = Number.parseInt(rawValue, 10);
275
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2500;
276
+ })();
277
+ const DEFAULT_WORKSPACE_SCAN_MAX_FILES = (() => {
278
+ const rawValue = process.env.VIGTHORIA_WORKSPACE_SCAN_MAX_FILES || '1800';
279
+ const parsed = Number.parseInt(rawValue, 10);
280
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1800;
281
+ })();
245
282
  const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
246
283
  const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS;
247
284
  if (!rawValue) {
@@ -250,6 +287,16 @@ const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
250
287
  const parsed = Number.parseInt(rawValue, 10);
251
288
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
252
289
  })();
290
+ const DEFAULT_CHAT_CONNECT_TIMEOUT_MS = (() => {
291
+ const rawValue = process.env.VIGTHORIA_CHAT_CONNECT_TIMEOUT_MS || '20000';
292
+ const parsed = Number.parseInt(rawValue, 10);
293
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 20000;
294
+ })();
295
+ const DEFAULT_CHAT_IDLE_TIMEOUT_MS = (() => {
296
+ const rawValue = process.env.VIGTHORIA_CHAT_IDLE_TIMEOUT_MS || '120000';
297
+ const parsed = Number.parseInt(rawValue, 10);
298
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
299
+ })();
253
300
  export class APIClient {
254
301
  client;
255
302
  modelRouterClient;
@@ -291,7 +338,7 @@ export class APIClient {
291
338
  'User-Agent': `Vigthoria-CLI/${process.env.npm_package_version || '1.6.9'}`,
292
339
  },
293
340
  });
294
- // Self-hosted model router is opt-in only.
341
+ // Optional dedicated Vigthoria model endpoint for internal/server deployments only.
295
342
  const selfHostedModelsApiUrl = this.getSelfHostedModelsApiUrl();
296
343
  this.selfHostedModelRouterClient = selfHostedModelsApiUrl ? axios.create({
297
344
  baseURL: selfHostedModelsApiUrl,
@@ -581,16 +628,20 @@ export class APIClient {
581
628
  }
582
629
  getV3AgentBaseUrls(preferLocal = false) {
583
630
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
584
- const allowLocalV3Agent = preferLocal
585
- || process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
586
- || !isServerRuntime();
587
- const urls = [
631
+ const includeLoopbackV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
632
+ || (preferLocal && isServerRuntime());
633
+ const localCandidates = [
588
634
  process.env.VIGTHORIA_V3_AGENT_URL,
589
635
  process.env.V3_AGENT_URL,
590
- ...(allowLocalV3Agent ? ['http://127.0.0.1:8030'] : []),
636
+ ...(includeLoopbackV3Agent ? ['http://127.0.0.1:8030'] : []),
637
+ ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
638
+ const remoteCandidates = [
591
639
  configuredApiUrl,
592
640
  'https://coder.vigthoria.io',
593
641
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
642
+ const urls = preferLocal && localCandidates.length > 0
643
+ ? [...localCandidates, ...remoteCandidates]
644
+ : [...remoteCandidates, ...localCandidates];
594
645
  return [...new Set(urls)];
595
646
  }
596
647
  getV3AgentRunUrl(baseUrl) {
@@ -1043,6 +1094,7 @@ export class APIClient {
1043
1094
  async getV3AgentHeaders() {
1044
1095
  const headers = {
1045
1096
  'Content-Type': 'application/json',
1097
+ Accept: 'text/event-stream',
1046
1098
  };
1047
1099
  const authToken = this.getAccessToken();
1048
1100
  if (authToken) {
@@ -1051,12 +1103,365 @@ export class APIClient {
1051
1103
  }
1052
1104
  const serviceKey = process.env.VIGTHORIA_V3_SERVICE_KEY
1053
1105
  || process.env.V3_SERVICE_KEY
1054
- || process.env.HYPERLOOP_SERVICE_KEY;
1106
+ || process.env.HYPERLOOP_SERVICE_KEY
1107
+ || this.config.get('v3ServiceKey');
1055
1108
  if (serviceKey) {
1056
1109
  headers['X-Service-Key'] = serviceKey;
1057
1110
  }
1058
1111
  return headers;
1059
1112
  }
1113
+ /**
1114
+ * Ensure the V3 service key is available in config.
1115
+ * If not already set via env or stored config, fetches it from the Coder API
1116
+ * using the current user's auth token and caches it for this session and beyond.
1117
+ */
1118
+ async ensureV3ServiceKey() {
1119
+ const existing = process.env.VIGTHORIA_V3_SERVICE_KEY
1120
+ || process.env.V3_SERVICE_KEY
1121
+ || process.env.HYPERLOOP_SERVICE_KEY
1122
+ || this.config.get('v3ServiceKey');
1123
+ if (existing)
1124
+ return;
1125
+ try {
1126
+ const token = this.getAccessToken();
1127
+ if (!token)
1128
+ return;
1129
+ const response = await this.client.get('/api/cli/v3-service-key', {
1130
+ headers: { Authorization: `Bearer ${token}` },
1131
+ timeout: 6000,
1132
+ });
1133
+ const key = response.data?.v3ServiceKey;
1134
+ if (key) {
1135
+ this.config.set('v3ServiceKey', key);
1136
+ this.logger.debug('V3 service key retrieved and cached from Coder API.');
1137
+ }
1138
+ }
1139
+ catch {
1140
+ // Non-fatal: fall through; the health check will surface the auth error.
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Fast preflight for local agent loop — probes model backends in parallel
1145
+ * so users see a clear pass/fail before "Planning..." hangs on slow routes.
1146
+ */
1147
+ async runChatModelPreflight(requestedModel = 'agent') {
1148
+ const routes = [];
1149
+ const probes = [
1150
+ {
1151
+ name: 'Vigthoria Coder API',
1152
+ run: async () => {
1153
+ const health = await this.getCoderHealth();
1154
+ return { ok: health.ok, error: health.error };
1155
+ },
1156
+ },
1157
+ {
1158
+ name: 'Vigthoria Models API',
1159
+ run: async () => {
1160
+ const health = await this.getModelsHealth();
1161
+ return { ok: health.ok, error: health.error };
1162
+ },
1163
+ },
1164
+ ];
1165
+ const selfHostedHealth = await this.getSelfHostedHealth();
1166
+ if (selfHostedHealth) {
1167
+ probes.unshift({
1168
+ name: selfHostedHealth.name || 'Dedicated Models API',
1169
+ run: async () => ({ ok: selfHostedHealth.ok, error: selfHostedHealth.error }),
1170
+ });
1171
+ }
1172
+ if (this.isSelfHostedPreferredModel(this.resolveModelId(requestedModel), requestedModel)) {
1173
+ // Agent/code models should also accept V3 health as a usable inference path.
1174
+ probes.push({
1175
+ name: 'V3 Agent API',
1176
+ run: async () => {
1177
+ const v3 = await this.runV3HealthCheck();
1178
+ return { ok: v3.healthy, error: v3.error };
1179
+ },
1180
+ });
1181
+ }
1182
+ const results = await Promise.all(probes.map(async (probe) => {
1183
+ try {
1184
+ const result = await probe.run();
1185
+ routes.push({ name: probe.name, ok: result.ok, error: result.error });
1186
+ return result.ok;
1187
+ }
1188
+ catch (error) {
1189
+ const message = error.message || String(error);
1190
+ routes.push({ name: probe.name, ok: false, error: message });
1191
+ return false;
1192
+ }
1193
+ }));
1194
+ const healthy = results.some(Boolean);
1195
+ const inferenceRoutes = routes.filter((route) => route.name !== 'V3 Agent API');
1196
+ const firstHealthy = inferenceRoutes.find((route) => route.ok) || routes.find((route) => route.ok);
1197
+ return {
1198
+ healthy,
1199
+ endpoint: firstHealthy?.name || 'api.vigthoria.io',
1200
+ error: healthy
1201
+ ? undefined
1202
+ : 'No Vigthoria model backend responded during preflight. Run `vigthoria login` and check your internet connection.',
1203
+ routes,
1204
+ };
1205
+ }
1206
+ async runV3HealthCheck(options = {}) {
1207
+ await this.ensureV3ServiceKey();
1208
+ const endpoints = this.getV3AgentBaseUrls(false);
1209
+ const headers = await this.getV3AgentHeaders();
1210
+ const perEndpointTimeoutMs = 8000;
1211
+ const probe = async (baseUrl) => {
1212
+ const runUrl = this.getV3AgentRunUrl(baseUrl);
1213
+ const healthUrl = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1214
+ ? `${baseUrl}/health`
1215
+ : `${baseUrl}/api/v3-agent/health`;
1216
+ try {
1217
+ const response = await fetch(healthUrl, {
1218
+ method: 'GET',
1219
+ headers,
1220
+ signal: AbortSignal.timeout(perEndpointTimeoutMs),
1221
+ });
1222
+ if (response.status === 401 || response.status === 403) {
1223
+ return {
1224
+ healthy: false,
1225
+ endpoint: runUrl,
1226
+ error: 'V3 service rejected authentication. Run: vigthoria login',
1227
+ };
1228
+ }
1229
+ if (response.ok || response.status === 405) {
1230
+ return { healthy: true, endpoint: runUrl };
1231
+ }
1232
+ if (response.status === 502 || response.status === 503 || response.status === 504) {
1233
+ return {
1234
+ healthy: false,
1235
+ endpoint: runUrl,
1236
+ error: `V3 agent proxy returned ${response.status}. The V3 Code Agent service may be restarting or overloaded.`,
1237
+ };
1238
+ }
1239
+ return {
1240
+ healthy: false,
1241
+ endpoint: runUrl,
1242
+ error: `V3 agent health returned ${response.status}`,
1243
+ };
1244
+ }
1245
+ catch (error) {
1246
+ const reason = error?.message || String(error);
1247
+ if (/timeout|aborted|timed out/i.test(reason)) {
1248
+ return {
1249
+ healthy: false,
1250
+ endpoint: runUrl,
1251
+ error: options.soft
1252
+ ? 'V3 health probe timed out; continuing with agent stream.'
1253
+ : 'V3 agent health check timed out. The service may be busy with another run or temporarily overloaded.',
1254
+ };
1255
+ }
1256
+ return {
1257
+ healthy: false,
1258
+ endpoint: runUrl,
1259
+ error: reason,
1260
+ };
1261
+ }
1262
+ };
1263
+ const results = await Promise.all(endpoints.map((baseUrl) => probe(baseUrl)));
1264
+ const healthy = results.find((entry) => entry.healthy);
1265
+ if (healthy) {
1266
+ return healthy;
1267
+ }
1268
+ const timedOut = results.find((entry) => /timed out/i.test(entry.error || ''));
1269
+ if (timedOut) {
1270
+ return timedOut;
1271
+ }
1272
+ return results[0] || {
1273
+ healthy: false,
1274
+ endpoint: this.getV3AgentRunUrl(endpoints[0] || 'https://coder.vigthoria.io'),
1275
+ error: 'V3 agent service is not responding.',
1276
+ };
1277
+ }
1278
+ resolveChatConnectTimeoutMs(options = {}) {
1279
+ if (options.connectTimeoutMs && options.connectTimeoutMs > 0) {
1280
+ return options.connectTimeoutMs;
1281
+ }
1282
+ if (options.timeoutMs && options.timeoutMs > 0 && options.timeoutMs <= 60000) {
1283
+ return options.timeoutMs;
1284
+ }
1285
+ return DEFAULT_CHAT_CONNECT_TIMEOUT_MS;
1286
+ }
1287
+ resolveChatIdleTimeoutMs(options = {}) {
1288
+ if (options.idleTimeoutMs && options.idleTimeoutMs > 0) {
1289
+ return options.idleTimeoutMs;
1290
+ }
1291
+ if (options.timeoutMs && options.timeoutMs > 60000) {
1292
+ return options.timeoutMs;
1293
+ }
1294
+ return DEFAULT_CHAT_IDLE_TIMEOUT_MS;
1295
+ }
1296
+ mapPreflightEndpointToRoute(endpoint) {
1297
+ const normalized = String(endpoint || '').toLowerCase();
1298
+ if (normalized.includes('coder'))
1299
+ return 'coder';
1300
+ if (normalized.includes('models'))
1301
+ return 'models';
1302
+ if (normalized.includes('dedicated') || normalized.includes('self-hosted') || normalized.includes('selfhosted')) {
1303
+ return 'selfhosted';
1304
+ }
1305
+ return null;
1306
+ }
1307
+ isCanonicalCoderDuplicate() {
1308
+ const apiUrl = String(this.config.get('apiUrl') || '').replace(/\/$/, '').toLowerCase();
1309
+ return apiUrl === 'https://coder.vigthoria.io';
1310
+ }
1311
+ async consumeOpenAIStreamResponse(response, idleTimeoutMs, onDelta) {
1312
+ if (!response.body) {
1313
+ throw new Error('Streaming response had no body');
1314
+ }
1315
+ const reader = response.body.getReader();
1316
+ const decoder = new TextDecoder();
1317
+ let buffer = '';
1318
+ let content = '';
1319
+ let lastActivity = Date.now();
1320
+ const waitForChunk = async () => {
1321
+ const remainingIdle = idleTimeoutMs - (Date.now() - lastActivity);
1322
+ if (idleTimeoutMs > 0 && remainingIdle <= 0) {
1323
+ throw new Error('Model stream stalled (no output)');
1324
+ }
1325
+ const readPromise = reader.read();
1326
+ if (idleTimeoutMs <= 0) {
1327
+ return readPromise;
1328
+ }
1329
+ return Promise.race([
1330
+ readPromise,
1331
+ new Promise((_, reject) => {
1332
+ setTimeout(() => reject(new Error('Model stream stalled (no output)')), Math.max(1000, remainingIdle));
1333
+ }),
1334
+ ]);
1335
+ };
1336
+ while (true) {
1337
+ const { done, value } = await waitForChunk();
1338
+ if (done) {
1339
+ break;
1340
+ }
1341
+ lastActivity = Date.now();
1342
+ buffer += decoder.decode(value, { stream: true });
1343
+ const frames = buffer.split(/\r?\n\r?\n/);
1344
+ buffer = frames.pop() || '';
1345
+ for (const frame of frames) {
1346
+ const dataLines = frame
1347
+ .split(/\r?\n/)
1348
+ .filter((line) => line.startsWith('data:'))
1349
+ .map((line) => line.slice(5).trimStart());
1350
+ const payload = dataLines.join('\n').trim();
1351
+ if (!payload || payload === '[DONE]') {
1352
+ continue;
1353
+ }
1354
+ try {
1355
+ const parsed = JSON.parse(payload);
1356
+ const delta = parsed?.choices?.[0]?.delta?.content
1357
+ || parsed?.choices?.[0]?.message?.content
1358
+ || parsed?.choices?.[0]?.text
1359
+ || '';
1360
+ if (typeof delta === 'string' && delta) {
1361
+ content += delta;
1362
+ onDelta?.(delta);
1363
+ lastActivity = Date.now();
1364
+ }
1365
+ }
1366
+ catch {
1367
+ // Ignore malformed stream frames.
1368
+ }
1369
+ }
1370
+ }
1371
+ return content.trim();
1372
+ }
1373
+ async tryStreamingModelRouterChat(baseURL, messages, resolvedModel, requestedModel, headers, options) {
1374
+ const connectTimeoutMs = this.resolveChatConnectTimeoutMs(options);
1375
+ const idleTimeoutMs = this.resolveChatIdleTimeoutMs(options);
1376
+ const controller = new AbortController();
1377
+ const connectTimer = setTimeout(() => controller.abort(), connectTimeoutMs);
1378
+ try {
1379
+ const response = await fetch(`${baseURL.replace(/\/$/, '')}/v1/chat/completions`, {
1380
+ method: 'POST',
1381
+ headers: {
1382
+ 'Content-Type': 'application/json',
1383
+ ...headers,
1384
+ },
1385
+ body: JSON.stringify({
1386
+ model: resolvedModel,
1387
+ messages,
1388
+ max_tokens: this.config.get('preferences').maxTokens,
1389
+ temperature: 0.7,
1390
+ stream: true,
1391
+ }),
1392
+ signal: controller.signal,
1393
+ });
1394
+ if (!response.ok) {
1395
+ return null;
1396
+ }
1397
+ clearTimeout(connectTimer);
1398
+ const content = await this.consumeOpenAIStreamResponse(response, idleTimeoutMs, options.onStreamDelta);
1399
+ if (!content) {
1400
+ return null;
1401
+ }
1402
+ return {
1403
+ id: `vigthoria-stream-${Date.now()}`,
1404
+ message: content,
1405
+ model: resolvedModel || requestedModel,
1406
+ };
1407
+ }
1408
+ catch (error) {
1409
+ clearTimeout(connectTimer);
1410
+ throw error;
1411
+ }
1412
+ }
1413
+ async runV3AgentAuthPreflight(baseUrl, body, executionContext) {
1414
+ const endpoint = this.getV3AgentRunUrl(baseUrl);
1415
+ const healthEndpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
1416
+ ? `${baseUrl}/health`
1417
+ : `${baseUrl}/api/v3-agent/health`;
1418
+ const headers = await this.getV3AgentHeaders();
1419
+ headers['X-Vigthoria-Preflight'] = '1';
1420
+ if (executionContext.mcpContextId) {
1421
+ headers['X-MCP-Context-Id'] = String(executionContext.mcpContextId);
1422
+ }
1423
+ const controller = new AbortController();
1424
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
1425
+ try {
1426
+ const response = await fetch(healthEndpoint, {
1427
+ method: 'GET',
1428
+ headers,
1429
+ signal: controller.signal,
1430
+ });
1431
+ if (response.status === 401 || response.status === 403) {
1432
+ const errorText = await response.text().catch(() => '');
1433
+ return {
1434
+ ok: false,
1435
+ status: response.status,
1436
+ endpoint: healthEndpoint,
1437
+ reason: sanitizeUserFacingErrorText(errorText).slice(0, 200) || 'Unauthorized',
1438
+ };
1439
+ }
1440
+ // Any non-auth response means token acceptance passed at the V3 service layer.
1441
+ return { ok: true, status: response.status, endpoint: healthEndpoint };
1442
+ }
1443
+ catch (error) {
1444
+ const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 200);
1445
+ // Preflight should not block agent execution on transient timeout/abort network conditions.
1446
+ if (/aborted|timeout|timed out|network/i.test(reason)) {
1447
+ return {
1448
+ ok: true,
1449
+ status: 0,
1450
+ endpoint: healthEndpoint,
1451
+ reason,
1452
+ };
1453
+ }
1454
+ return {
1455
+ ok: false,
1456
+ status: 0,
1457
+ endpoint: healthEndpoint,
1458
+ reason,
1459
+ };
1460
+ }
1461
+ finally {
1462
+ clearTimeout(timeoutId);
1463
+ }
1464
+ }
1060
1465
  async executeV3AgentRunRequest(baseUrl, body, executionContext, signal) {
1061
1466
  const makeRequest = async () => {
1062
1467
  const headers = await this.getV3AgentHeaders();
@@ -1262,7 +1667,8 @@ export class APIClient {
1262
1667
  const targetPath = resolvedContext.targetPath || resolvedContext.projectPath || resolvedContext.workspacePath || resolvedContext.projectRoot || process.cwd();
1263
1668
  const localWorkspacePath = this.resolveAgentTargetPath(resolvedContext);
1264
1669
  const serverWorkspacePath = this.resolveServerBindableWorkspacePath(resolvedContext);
1265
- const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath);
1670
+ const prompt = String(resolvedContext.rawPrompt || resolvedContext.contextualPrompt || '').trim();
1671
+ const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath, { prompt });
1266
1672
  const requestedModel = String(resolvedContext.model || resolvedContext.requestedModel || 'agent');
1267
1673
  const resolvedModel = this.resolvePermittedModelId(requestedModel);
1268
1674
  const localWorkspaceName = localWorkspacePath ? path.basename(localWorkspacePath) : null;
@@ -1314,6 +1720,46 @@ export class APIClient {
1314
1720
  requestStartedAt: resolvedContext.requestStartedAt,
1315
1721
  subscriptionPlan: this.config.getNormalizedPlan() || null,
1316
1722
  email: this.config.get('email') || null,
1723
+ rawPrompt: prompt || null,
1724
+ promptFocusTerms: prompt ? this.extractPromptFocusTerms(prompt) : [],
1725
+ vigthoriaBrain: resolvedContext.vigthoriaBrain
1726
+ ? this.trimVigthoriaBrainForTransport(resolvedContext.vigthoriaBrain)
1727
+ : null,
1728
+ clientToolExecution: resolvedContext.clientToolExecution === true
1729
+ || (resolvedContext.localMachineCapable !== false
1730
+ && (resolvedContext.executionSurface === 'cli' || resolvedContext.clientSurface === 'cli')
1731
+ && !!localWorkspacePath
1732
+ && !serverWorkspacePath),
1733
+ clientToolPathRules: (resolvedContext.clientToolExecution === true || (resolvedContext.localMachineCapable !== false
1734
+ && (resolvedContext.executionSurface === 'cli' || resolvedContext.clientSurface === 'cli')
1735
+ && !!localWorkspacePath
1736
+ && !serverWorkspacePath))
1737
+ ? [
1738
+ 'Client-side read tools execute on the user real machine via the CLI.',
1739
+ 'Use paths relative to the workspace root only.',
1740
+ 'Never prefix tool paths with workspace/ or vigthoria://workspace/.',
1741
+ `The workspace root folder name is "${localWorkspaceName || 'project'}". Do not repeat it in paths.`,
1742
+ 'Examples: ".", "Vigthoria-dominion/README.md", "vigthoria-dominion-win/".',
1743
+ ].join(' ')
1744
+ : null,
1745
+ executionHints: {
1746
+ ...(resolvedContext.agentTaskType === 'analysis'
1747
+ ? {
1748
+ max_iterations: Number.parseInt(process.env.VIGTHORIA_ANALYSIS_MAX_ITERATIONS || '15', 10) || 15,
1749
+ analysis_guidance: this.buildAnalysisGuidance(Array.isArray(localWorkspaceSummary?.promptFocusDirectories)
1750
+ ? localWorkspaceSummary.promptFocusDirectories
1751
+ : [], Array.isArray(localWorkspaceSummary?.mandatoryReadPaths)
1752
+ ? localWorkspaceSummary.mandatoryReadPaths
1753
+ : []),
1754
+ }
1755
+ : {}),
1756
+ ...(['implementation', 'game-build', 'web-build', 'build', 'fix'].includes(String(resolvedContext.agentTaskType || '').toLowerCase())
1757
+ ? {
1758
+ requires_file_changes: true,
1759
+ task_kind: String(resolvedContext.agentTaskType || 'implementation').toLowerCase(),
1760
+ }
1761
+ : {}),
1762
+ },
1317
1763
  };
1318
1764
  return this.compactV3Context(payload);
1319
1765
  }
@@ -1341,9 +1787,15 @@ export class APIClient {
1341
1787
  const overhead = json.length - JSON.stringify(summary.workspaceFiles).length;
1342
1788
  const budget = LIMIT - overhead - 512; // reserve a little headroom
1343
1789
  if (budget > 0) {
1790
+ const focusTerms = Array.isArray(payload.promptFocusTerms) ? payload.promptFocusTerms : [];
1791
+ const focusDirectories = Array.isArray(payload.localWorkspaceSummary?.promptFocusDirectories)
1792
+ ? payload.localWorkspaceSummary.promptFocusDirectories
1793
+ : [];
1794
+ const sortedEntries = [...fileEntries].sort((a, b) => this.scoreWorkspacePathForHydration(b[0], focusTerms, focusDirectories)
1795
+ - this.scoreWorkspacePathForHydration(a[0], focusTerms, focusDirectories));
1344
1796
  const trimmed = {};
1345
1797
  let used = 2; // {}
1346
- for (const [k, v] of fileEntries) {
1798
+ for (const [k, v] of sortedEntries) {
1347
1799
  const entryLen = JSON.stringify(k).length + 1 + JSON.stringify(v).length + 1;
1348
1800
  if (used + entryLen > budget)
1349
1801
  break;
@@ -1909,7 +2361,8 @@ menu {
1909
2361
  const headers = await this.getMcpHeaders();
1910
2362
  const localWorkspacePath = this.resolveAgentTargetPath(executionContext);
1911
2363
  const workspacePath = this.resolveServerBindableWorkspacePath(executionContext);
1912
- const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath);
2364
+ const prompt = String(executionContext.rawPrompt || executionContext.contextualPrompt || '').trim();
2365
+ const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath, { prompt });
1913
2366
  const metadata = {
1914
2367
  source: 'vigthoria-cli',
1915
2368
  sharedContextId: executionContext.contextId,
@@ -1945,6 +2398,7 @@ menu {
1945
2398
  method: 'PUT',
1946
2399
  headers,
1947
2400
  body: JSON.stringify({ data }),
2401
+ signal: AbortSignal.timeout(DEFAULT_MCP_BIND_TIMEOUT_MS),
1948
2402
  });
1949
2403
  if (!response.ok) {
1950
2404
  const errorText = await response.text().catch(() => '');
@@ -1976,6 +2430,7 @@ menu {
1976
2430
  userId: this.config.get('userId') || this.config.get('email') || 'vigthoria-cli',
1977
2431
  metadata,
1978
2432
  }),
2433
+ signal: AbortSignal.timeout(DEFAULT_MCP_BIND_TIMEOUT_MS),
1979
2434
  });
1980
2435
  if (!createResponse.ok) {
1981
2436
  const errorText = await createResponse.text().catch(() => '');
@@ -2050,6 +2505,7 @@ menu {
2050
2505
  }
2051
2506
  const localName = paths.localWorkspacePath ? path.basename(paths.localWorkspacePath) : null;
2052
2507
  const serverBindableWorkspace = !!paths.serverWorkspacePath || runtime.serverBindableWorkspace === true;
2508
+ const isLocalMachine = !serverBindableWorkspace;
2053
2509
  return {
2054
2510
  osPlatform: runtime.osPlatform || null,
2055
2511
  platform: runtime.platform || null,
@@ -2060,17 +2516,273 @@ menu {
2060
2516
  workspaceName: localName,
2061
2517
  workspacePath: serverBindableWorkspace ? paths.serverWorkspacePath || null : (localName ? `vigthoria://local-workspace/${localName}` : null),
2062
2518
  cwd: serverBindableWorkspace ? paths.serverWorkspacePath || null : (localName ? `vigthoria://local-workspace/${localName}` : null),
2519
+ localExecutionRequired: isLocalMachine,
2520
+ toolExecutionSurface: isLocalMachine ? 'cli-local' : 'server-workspace',
2521
+ executionGuidance: isLocalMachine
2522
+ ? 'The user runs Vigthoria CLI on their local machine. Real files and folders live on the client filesystem. Server workspace tools only see a partial hydrated copy — read `.vigthoria-workspace-index.md` and `promptFocusDirectories` before guessing root-level README.md. Prefer localWorkspaceSummary for structure and tell the user when live local tool execution is required.'
2523
+ : null,
2524
+ };
2525
+ }
2526
+ extractPromptFocusTerms(prompt) {
2527
+ const terms = new Set();
2528
+ const normalized = String(prompt || '').trim();
2529
+ if (!normalized) {
2530
+ return [];
2531
+ }
2532
+ for (const match of normalized.matchAll(/["'`]([^"'`]{2,120})["'`]/g)) {
2533
+ terms.add(match[1].trim());
2534
+ }
2535
+ for (const match of normalized.matchAll(/(?:project|game|folder|directory|path|repo(?:sitory)?)\s+([A-Za-z0-9][\w ._-]{2,80})/gi)) {
2536
+ terms.add(match[1].trim());
2537
+ }
2538
+ for (const match of normalized.matchAll(/(?:investigate|find|explore|look at|check|inspect|analyse|analyze)\s+(?:the\s+)?([A-Za-z0-9][\w ._-]{2,80}?)(?:\s+(?:folder|directory|foulder|dir|project|game))?\b/gi)) {
2539
+ terms.add(match[1].trim());
2540
+ }
2541
+ for (const match of normalized.matchAll(/\b([A-Z][a-z0-9]+(?:\s+[A-Z][a-z0-9]+)+)\b/g)) {
2542
+ const phrase = match[1].trim();
2543
+ if (phrase.length <= 48) {
2544
+ terms.add(phrase);
2545
+ }
2546
+ }
2547
+ for (const match of normalized.matchAll(/\/([\w .-]{2,80})/g)) {
2548
+ const segment = match[1].trim();
2549
+ if (!/^(users|home|desktop|documents|var|www|c)$/i.test(segment)) {
2550
+ terms.add(segment);
2551
+ }
2552
+ }
2553
+ return [...terms].filter((term) => term.length >= 3 && term.length <= 48).slice(0, 8);
2554
+ }
2555
+ buildTopLevelWorkspaceLayout(rootPath) {
2556
+ const skipDirs = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'coverage', '.cache', '__pycache__', '.venv', 'venv']);
2557
+ const layout = { directories: [], files: [] };
2558
+ try {
2559
+ for (const entry of fs.readdirSync(rootPath, { withFileTypes: true })) {
2560
+ if (entry.name.startsWith('.')) {
2561
+ continue;
2562
+ }
2563
+ if (entry.isDirectory()) {
2564
+ if (!skipDirs.has(entry.name)) {
2565
+ layout.directories.push(entry.name);
2566
+ }
2567
+ }
2568
+ else if (entry.isFile()) {
2569
+ layout.files.push(entry.name);
2570
+ }
2571
+ }
2572
+ }
2573
+ catch {
2574
+ return layout;
2575
+ }
2576
+ layout.directories.sort((a, b) => a.localeCompare(b));
2577
+ layout.files.sort((a, b) => a.localeCompare(b));
2578
+ return layout;
2579
+ }
2580
+ normalizeFocusPathKey(value) {
2581
+ return String(value || '').toLowerCase().replace(/[\s_./\\-]+/g, '');
2582
+ }
2583
+ resolvePromptFocusDirectories(rootPath, focusTerms) {
2584
+ const layout = this.buildTopLevelWorkspaceLayout(rootPath);
2585
+ const matches = new Set();
2586
+ const normalizedTerms = focusTerms.map((term) => term.toLowerCase());
2587
+ const normalizedTermKeys = focusTerms.map((term) => this.normalizeFocusPathKey(term));
2588
+ for (const dir of layout.directories) {
2589
+ const dirLower = dir.toLowerCase();
2590
+ const dirKey = this.normalizeFocusPathKey(dir);
2591
+ for (let index = 0; index < normalizedTerms.length; index += 1) {
2592
+ const term = normalizedTerms[index];
2593
+ const termKey = normalizedTermKeys[index];
2594
+ if (dirLower === term
2595
+ || dirLower.includes(term)
2596
+ || term.includes(dirLower)
2597
+ || (termKey && (dirKey === termKey || dirKey.includes(termKey) || termKey.includes(dirKey)))) {
2598
+ matches.add(`${dir.replace(/\\/g, '/')}/`);
2599
+ }
2600
+ }
2601
+ }
2602
+ for (const term of focusTerms) {
2603
+ const candidate = path.join(rootPath, term);
2604
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
2605
+ matches.add(`${term.replace(/\\/g, '/')}/`);
2606
+ }
2607
+ }
2608
+ return [...matches];
2609
+ }
2610
+ buildMandatoryFocusReadPaths(rootPath, focusDirectories) {
2611
+ const candidates = [];
2612
+ const pushIfExists = (relativePath) => {
2613
+ const normalized = relativePath.replace(/\\/g, '/');
2614
+ const absolutePath = path.join(rootPath, normalized);
2615
+ if (fs.existsSync(absolutePath)) {
2616
+ candidates.push(normalized);
2617
+ }
2063
2618
  };
2619
+ for (const focusDir of focusDirectories) {
2620
+ const base = focusDir.replace(/\\/g, '/').replace(/\/$/, '');
2621
+ pushIfExists(`${base}/package.json`);
2622
+ pushIfExists(`${base}/game.js`);
2623
+ pushIfExists(`${base}/src/Game.js`);
2624
+ pushIfExists(`${base}/src/factions/FactionModels.js`);
2625
+ pushIfExists(`${base}/GAME_DESIGN_DOCUMENT.md`);
2626
+ }
2627
+ const layout = this.buildTopLevelWorkspaceLayout(rootPath);
2628
+ for (const dir of layout.directories) {
2629
+ if (/dominion/i.test(dir) && /-win|desktop|electron/i.test(dir)) {
2630
+ pushIfExists(`${dir.replace(/\\/g, '/')}/`);
2631
+ }
2632
+ }
2633
+ return [...new Set(candidates)];
2634
+ }
2635
+ buildAnalysisGuidance(focusDirectories, mandatoryReadPaths) {
2636
+ const focusLabel = focusDirectories.length > 0
2637
+ ? focusDirectories.map((entry) => entry.replace(/\/$/, '')).join(', ')
2638
+ : 'the requested project folder';
2639
+ const reads = mandatoryReadPaths.length > 0
2640
+ ? mandatoryReadPaths.slice(0, 12).map((entry) => `- \`${entry}\``).join('\n')
2641
+ : '- package.json\n- game.js\n- src/Game.js\n- src/factions/\n- GAME_DESIGN_DOCUMENT.md';
2642
+ return [
2643
+ `The user asked for a grounded overview of ${focusLabel}.`,
2644
+ 'Do NOT summarize from root README.md, PRODUCTION_ROADMAP.md, or PROJECT_STATUS_REPORT.md alone — they may be stale or AI-generated.',
2645
+ 'Read these code-first paths before writing your final answer:',
2646
+ reads,
2647
+ 'MANDATORY: Compare README claims against package.json, game.js, and src/. If they disagree, your final answer MUST:',
2648
+ '1) State what the game actually is based on code,',
2649
+ '2) Quote what README incorrectly claims,',
2650
+ '3) Explicitly say "README is inconsistent with the codebase" and recommend fixing it.',
2651
+ 'List `vigthoria-dominion-win/` (or similar) if present to confirm desktop/Electron distribution.',
2652
+ ].join('\n');
2653
+ }
2654
+ collectFocusDirectoryFilePaths(rootPath, focusDirRelative, maxFiles = 250) {
2655
+ const focusRoot = path.join(rootPath, focusDirRelative);
2656
+ if (!fs.existsSync(focusRoot)) {
2657
+ return [];
2658
+ }
2659
+ const skipDirs = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'coverage', '.cache', '__pycache__', '.venv', 'venv']);
2660
+ const results = [];
2661
+ const stack = [{ abs: focusRoot, rel: focusDirRelative.replace(/\\/g, '/') }];
2662
+ while (stack.length > 0 && results.length < maxFiles) {
2663
+ const current = stack.pop();
2664
+ if (!current) {
2665
+ continue;
2666
+ }
2667
+ let entries;
2668
+ try {
2669
+ entries = fs.readdirSync(current.abs, { withFileTypes: true });
2670
+ }
2671
+ catch {
2672
+ continue;
2673
+ }
2674
+ for (const entry of entries) {
2675
+ if (results.length >= maxFiles) {
2676
+ break;
2677
+ }
2678
+ const rel = `${current.rel}${current.rel.endsWith('/') ? '' : '/'}${entry.name}`.replace(/\\/g, '/');
2679
+ const abs = path.join(current.abs, entry.name);
2680
+ if (entry.isDirectory()) {
2681
+ if (!skipDirs.has(entry.name) && !entry.name.startsWith('.')) {
2682
+ stack.push({ abs, rel: `${rel}/` });
2683
+ }
2684
+ }
2685
+ else if (entry.isFile()) {
2686
+ results.push(rel);
2687
+ }
2688
+ }
2689
+ }
2690
+ return results;
2691
+ }
2692
+ mergeWorkspacePathCandidates(primary, extras) {
2693
+ const seen = new Set();
2694
+ const merged = [];
2695
+ for (const candidate of [...extras, ...primary]) {
2696
+ const normalized = candidate.replace(/\\/g, '/');
2697
+ if (!normalized || seen.has(normalized)) {
2698
+ continue;
2699
+ }
2700
+ seen.add(normalized);
2701
+ merged.push(normalized);
2702
+ }
2703
+ return merged;
2704
+ }
2705
+ scoreWorkspacePathForHydration(filePath, focusTerms, focusDirectories = []) {
2706
+ const normalized = filePath.replace(/\\/g, '/').toLowerCase();
2707
+ let score = 0;
2708
+ if (normalized === '.vigthoria-workspace-index.md') {
2709
+ score += 1000;
2710
+ }
2711
+ if (normalized.endsWith('package.json')) {
2712
+ score += 360;
2713
+ }
2714
+ if (/(^|\/)game\.js$/i.test(normalized) || /\/src\/game\.js$/i.test(normalized)) {
2715
+ score += 350;
2716
+ }
2717
+ if (/\/src\/factions\//i.test(normalized) || /factionmodels\.js/i.test(normalized)) {
2718
+ score += 340;
2719
+ }
2720
+ if (/game_design_document\.md/i.test(normalized)) {
2721
+ score += 320;
2722
+ }
2723
+ if (normalized.endsWith('readme.md')) {
2724
+ score += focusDirectories.some((dir) => normalized.startsWith(dir.toLowerCase())) ? 120 : 40;
2725
+ }
2726
+ if (/production_roadmap|project_status_report|status_report|roadmap\.md/i.test(normalized)) {
2727
+ score += 20;
2728
+ }
2729
+ if (/\.(js|ts|tsx|jsx|mjs|cjs)$/i.test(normalized)) {
2730
+ score += 100;
2731
+ }
2732
+ if (/\.(md|txt|json|yaml|yml|toml)$/i.test(normalized)) {
2733
+ score += 40;
2734
+ }
2735
+ for (const dir of focusDirectories.map((entry) => entry.toLowerCase())) {
2736
+ if (normalized.startsWith(dir)) {
2737
+ score += 250;
2738
+ }
2739
+ }
2740
+ for (const term of focusTerms) {
2741
+ const termLower = term.toLowerCase();
2742
+ const termKey = this.normalizeFocusPathKey(term);
2743
+ if (normalized.includes(termLower) || (termKey && normalized.replace(/[\s_./\\-]+/g, '').includes(termKey))) {
2744
+ score += 80;
2745
+ }
2746
+ }
2747
+ return score;
2748
+ }
2749
+ sortWorkspacePathsForHydration(filePaths, focusTerms, focusDirectories) {
2750
+ return [...filePaths].sort((a, b) => this.scoreWorkspacePathForHydration(b, focusTerms, focusDirectories)
2751
+ - this.scoreWorkspacePathForHydration(a, focusTerms, focusDirectories));
2752
+ }
2753
+ trimVigthoriaBrainForTransport(brain, maxChars = 12000) {
2754
+ try {
2755
+ const serialized = typeof brain === 'string' ? brain : JSON.stringify(brain);
2756
+ if (serialized.length <= maxChars) {
2757
+ return typeof brain === 'string' ? brain : JSON.parse(serialized);
2758
+ }
2759
+ return {
2760
+ truncated: true,
2761
+ excerpt: serialized.slice(0, maxChars),
2762
+ };
2763
+ }
2764
+ catch {
2765
+ return null;
2766
+ }
2064
2767
  }
2065
- buildLocalWorkspaceSummary(rootPath) {
2768
+ buildLocalWorkspaceSummary(rootPath, options = {}) {
2066
2769
  if (!rootPath || !fs.existsSync(rootPath)) {
2067
2770
  return null;
2068
2771
  }
2069
2772
  try {
2773
+ const prompt = String(options.prompt || '').trim();
2774
+ const focusTerms = prompt ? this.extractPromptFocusTerms(prompt) : [];
2775
+ const topLevelLayout = this.buildTopLevelWorkspaceLayout(rootPath);
2776
+ const focusDirectories = this.resolvePromptFocusDirectories(rootPath, focusTerms);
2777
+ const mandatoryReadPaths = this.buildMandatoryFocusReadPaths(rootPath, focusDirectories);
2070
2778
  const summary = {
2071
2779
  path: 'vigthoria://workspace/',
2072
2780
  name: path.basename(rootPath),
2073
2781
  files: [],
2782
+ topLevelLayout,
2783
+ promptFocusTerms: focusTerms,
2784
+ promptFocusDirectories: focusDirectories,
2785
+ mandatoryReadPaths,
2074
2786
  };
2075
2787
  const snapshot = this.getAgentWorkspaceSnapshot(rootPath);
2076
2788
  summary.fileCount = snapshot.fileCount;
@@ -2087,12 +2799,34 @@ menu {
2087
2799
  };
2088
2800
  }
2089
2801
  const readmePath = path.join(rootPath, 'README.md');
2090
- if (fs.existsSync(readmePath)) {
2802
+ if (focusDirectories.length === 0 && fs.existsSync(readmePath)) {
2091
2803
  summary.readmeExcerpt = fs.readFileSync(readmePath, 'utf8').slice(0, 2500);
2092
2804
  }
2805
+ else if (focusDirectories.length > 0) {
2806
+ summary.readmeWarning = 'Root README and planning docs are often stale or agent-generated. Prefer package.json, game.js, src/, and GAME_DESIGN_DOCUMENT.md inside the focus folder.';
2807
+ }
2808
+ for (const focusDir of focusDirectories) {
2809
+ const focusReadme = path.join(rootPath, focusDir, 'README.md');
2810
+ if (fs.existsSync(focusReadme)) {
2811
+ summary.focusReadmeWarning = summary.focusReadmeWarning || {};
2812
+ summary.focusReadmeWarning[`${focusDir}README.md`.replace(/\\/g, '/')] =
2813
+ 'Subfolder README may be stale or wrong. Prefer package.json, game.js, src/, and GAME_DESIGN_DOCUMENT.md.';
2814
+ }
2815
+ }
2816
+ const focusPaths = [
2817
+ ...mandatoryReadPaths,
2818
+ ...focusDirectories.flatMap((focusDir) => this.collectFocusDirectoryFilePaths(rootPath, focusDir)),
2819
+ ];
2820
+ const hydrationPaths = this.sortWorkspacePathsForHydration(this.mergeWorkspacePathCandidates(snapshot.paths, focusPaths), focusTerms, focusDirectories);
2093
2821
  // Hydrate workspace: include actual file contents so the V3 server
2094
2822
  // can populate the remote workspace before the agent starts.
2095
- summary.workspaceFiles = this.collectWorkspaceFileContents(rootPath, snapshot.paths);
2823
+ summary.workspaceFiles = this.collectWorkspaceFileContents(rootPath, hydrationPaths);
2824
+ summary.workspaceFiles['.vigthoria-workspace-index.md'] = this.buildWorkspaceIndexFile(rootPath, snapshot, {
2825
+ focusTerms,
2826
+ focusDirectories,
2827
+ topLevelLayout,
2828
+ mandatoryReadPaths,
2829
+ });
2096
2830
  return summary;
2097
2831
  }
2098
2832
  catch (error) {
@@ -2100,6 +2834,61 @@ menu {
2100
2834
  return null;
2101
2835
  }
2102
2836
  }
2837
+ buildWorkspaceIndexFile(rootPath, snapshot, options = {}) {
2838
+ const workspaceName = path.basename(rootPath);
2839
+ const listedPaths = snapshot.paths.slice(0, 1800);
2840
+ const focusTerms = options.focusTerms || [];
2841
+ const focusDirectories = options.focusDirectories || [];
2842
+ const mandatoryReadPaths = options.mandatoryReadPaths || [];
2843
+ const topLevelLayout = options.topLevelLayout || this.buildTopLevelWorkspaceLayout(rootPath);
2844
+ const lines = [
2845
+ '# Vigthoria Local Workspace Index',
2846
+ '',
2847
+ 'This file is generated by the Vigthoria CLI so the remote V3 agent can search and understand the local workspace layout without seeing private machine paths.',
2848
+ 'Read this file before guessing paths like README.md at the workspace root.',
2849
+ 'README.md, PRODUCTION_ROADMAP.md, and PROJECT_STATUS_REPORT.md may be stale or AI-generated. Trust package.json, game.js, src/, and GAME_DESIGN_DOCUMENT.md instead.',
2850
+ '',
2851
+ '## Path rules for client-side tools',
2852
+ '- The workspace root is already bound to the user folder below.',
2853
+ '- Use relative paths from that root: `.`, `Vigthoria-dominion/`, `README.md`.',
2854
+ '- NEVER prefix paths with `workspace/` — that alias does not exist on the user machine.',
2855
+ '- Do NOT repeat the workspace folder name in paths.',
2856
+ '',
2857
+ `Workspace name: ${workspaceName}`,
2858
+ `Indexed files: ${snapshot.fileCount}`,
2859
+ '',
2860
+ '## Top-level layout',
2861
+ ...(topLevelLayout.directories.length > 0
2862
+ ? topLevelLayout.directories.map((dir) => `- ${dir}/`)
2863
+ : ['- (no subdirectories listed)']),
2864
+ ...(topLevelLayout.files.length > 0
2865
+ ? ['', '## Top-level files', ...topLevelLayout.files.map((file) => `- ${file}`)]
2866
+ : []),
2867
+ ];
2868
+ if (focusTerms.length > 0 || focusDirectories.length > 0) {
2869
+ lines.push('', '## Prompt focus');
2870
+ if (focusTerms.length > 0) {
2871
+ lines.push(`User focus terms: ${focusTerms.join(', ')}`);
2872
+ }
2873
+ if (focusDirectories.length > 0) {
2874
+ lines.push('Likely project folders:');
2875
+ for (const focusDir of focusDirectories) {
2876
+ lines.push(`- ${focusDir}`);
2877
+ }
2878
+ }
2879
+ if (mandatoryReadPaths.length > 0) {
2880
+ lines.push('', '## Mandatory code-first reads (before summarizing)');
2881
+ for (const readPath of mandatoryReadPaths.slice(0, 12)) {
2882
+ lines.push(`- ${readPath}`);
2883
+ }
2884
+ }
2885
+ }
2886
+ lines.push('', '## Files', ...listedPaths.map((filePath) => `- ${filePath.replace(/\\/g, '/')}`));
2887
+ if (snapshot.fileCount > listedPaths.length) {
2888
+ lines.push('', `Index truncated: ${snapshot.fileCount - listedPaths.length} additional files were not listed.`);
2889
+ }
2890
+ return `${lines.join('\n')}\n`;
2891
+ }
2103
2892
  /**
2104
2893
  * Collect text file contents from the workspace for V3 agent hydration.
2105
2894
  * Budget: up to ~2 MB total, per-file cap 200 KB, skip binary extensions.
@@ -2152,14 +2941,24 @@ menu {
2152
2941
  if (!root || !fs.existsSync(root)) {
2153
2942
  return false;
2154
2943
  }
2944
+ const skipDirs = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'coverage', '.cache', '__pycache__', '.venv', 'venv']);
2945
+ const startedAt = Date.now();
2155
2946
  const stack = [root];
2947
+ let scannedDirs = 0;
2156
2948
  while (stack.length > 0) {
2949
+ if (Date.now() - startedAt > DEFAULT_WORKSPACE_SCAN_TIMEOUT_MS) {
2950
+ return false;
2951
+ }
2157
2952
  const current = stack.pop();
2158
2953
  if (!current)
2159
2954
  continue;
2955
+ scannedDirs += 1;
2956
+ if (scannedDirs > DEFAULT_WORKSPACE_SCAN_MAX_FILES) {
2957
+ return false;
2958
+ }
2160
2959
  const entries = fs.readdirSync(current, { withFileTypes: true });
2161
2960
  for (const entry of entries) {
2162
- if (entry.name === '.git' || entry.name === 'node_modules') {
2961
+ if (skipDirs.has(entry.name)) {
2163
2962
  continue;
2164
2963
  }
2165
2964
  const fullPath = path.join(current, entry.name);
@@ -2178,16 +2977,24 @@ menu {
2178
2977
  return false;
2179
2978
  }
2180
2979
  getAgentWorkspaceSnapshot(rootPath) {
2980
+ const skipDirs = new Set(['.git', 'node_modules', '.next', 'dist', 'build', 'coverage', '.cache', '__pycache__', '.venv', 'venv']);
2181
2981
  const stack = [rootPath];
2982
+ const startedAt = Date.now();
2182
2983
  let fileCount = 0;
2183
2984
  const entries = [];
2184
2985
  while (stack.length > 0) {
2986
+ if (Date.now() - startedAt > DEFAULT_WORKSPACE_SCAN_TIMEOUT_MS) {
2987
+ break;
2988
+ }
2989
+ if (entries.length >= DEFAULT_WORKSPACE_SCAN_MAX_FILES) {
2990
+ break;
2991
+ }
2185
2992
  const current = stack.pop();
2186
2993
  if (!current) {
2187
2994
  continue;
2188
2995
  }
2189
2996
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
2190
- if (entry.name === '.git' || entry.name === 'node_modules') {
2997
+ if (skipDirs.has(entry.name)) {
2191
2998
  continue;
2192
2999
  }
2193
3000
  const fullPath = path.join(current, entry.name);
@@ -2201,6 +3008,9 @@ menu {
2201
3008
  fileCount += 1;
2202
3009
  const stat = fs.statSync(fullPath);
2203
3010
  entries.push(`${path.relative(rootPath, fullPath)}:${stat.size}:${stat.mtimeMs}`);
3011
+ if (entries.length >= DEFAULT_WORKSPACE_SCAN_MAX_FILES) {
3012
+ break;
3013
+ }
2204
3014
  }
2205
3015
  }
2206
3016
  entries.sort();
@@ -2843,14 +3653,14 @@ document.addEventListener('DOMContentLoaded', () => {
2843
3653
  if (typeof result === 'string') {
2844
3654
  return sanitizeUserFacingPathText(result);
2845
3655
  }
2846
- if (typeof result?.summary === 'string' && result.summary.trim()) {
3656
+ if (typeof result?.summary === 'string' && result.summary.trim() && !this.isGenericV3AgentSummary(result.summary)) {
2847
3657
  return sanitizeUserFacingPathText(result.summary);
2848
3658
  }
2849
- if (typeof result?.message === 'string' && result.message.trim()) {
3659
+ if (typeof result?.message === 'string' && result.message.trim() && !this.isGenericV3AgentSummary(result.message)) {
2850
3660
  return sanitizeUserFacingPathText(result.message);
2851
3661
  }
2852
3662
  if (Array.isArray(data?.events)) {
2853
- const completionEvent = [...data.events].reverse().find((event) => event && event.type === 'complete' && typeof event.summary === 'string' && event.summary.trim());
3663
+ const completionEvent = [...data.events].reverse().find((event) => event && event.type === 'complete' && typeof event.summary === 'string' && event.summary.trim() && !this.isGenericV3AgentSummary(event.summary));
2854
3664
  if (completionEvent) {
2855
3665
  return sanitizeUserFacingPathText(completionEvent.summary);
2856
3666
  }
@@ -2860,77 +3670,463 @@ document.addEventListener('DOMContentLoaded', () => {
2860
3670
  }
2861
3671
  // Synthesize a grounded answer from the tool-call evidence the
2862
3672
  // agent produced, rather than dumping the raw event trace.
2863
- const answer = this.synthesizeAnswerFromV3Events(data.events);
3673
+ const answer = this.synthesizeAnswerFromV3Events(data.events, data.liveToolEvidence);
2864
3674
  if (answer) {
2865
3675
  return answer;
2866
3676
  }
2867
3677
  }
2868
- // Last resort: if data has files written, report them.
2869
- if (data?.files && typeof data.files === 'object' && Object.keys(data.files).length > 0) {
3678
+ if (Array.isArray(data?.liveToolEvidence) && data.liveToolEvidence.length > 0) {
3679
+ const answer = this.synthesizeAnswerFromV3Events([], data.liveToolEvidence);
3680
+ if (answer) {
3681
+ return answer;
3682
+ }
3683
+ }
3684
+ // Last resort: if data has files written, report them.
3685
+ if (data?.files && typeof data.files === 'object' && Object.keys(data.files).length > 0) {
2870
3686
  const fileList = Object.keys(data.files).join(', ');
2871
3687
  return `Agent wrote workspace files: ${sanitizeUserFacingPathText(fileList)}`;
2872
3688
  }
2873
3689
  const text = sanitizeUserFacingPathText(JSON.stringify(data, null, 2));
2874
3690
  return text.length > 12000 ? `${text.slice(0, 12000)}\n\n[V3 agent output truncated]` : text;
2875
3691
  }
3692
+ isGenericV3AgentSummary(value) {
3693
+ const text = String(value || '').trim();
3694
+ if (!text)
3695
+ return true;
3696
+ return /^(task completed|v3 agent workflow completed\.?|agent run finished|workflow completed\.?|max iterations \(\d+\) reached after\b)/i.test(text);
3697
+ }
3698
+ isV3StreamStatusMessage(value) {
3699
+ const text = String(value || '').trim();
3700
+ if (!text)
3701
+ return true;
3702
+ return /^(the agent produced an in-progress note|continuing to finish cleanly|a previous tool call failed|max iterations \(\d+\) reached)/i.test(text)
3703
+ || /pending blocker|not been resolved|instead of a final report/i.test(text);
3704
+ }
3705
+ scoreEvidenceFilePath(filePath) {
3706
+ const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
3707
+ let score = 0;
3708
+ if (normalized.endsWith('package.json'))
3709
+ score += 100;
3710
+ if (/(^|\/)game\.js$/i.test(normalized) || /\/src\/game\.js$/i.test(normalized))
3711
+ score += 95;
3712
+ if (/\/src\/factions\//i.test(normalized) || /factionmodels\.js/i.test(normalized))
3713
+ score += 90;
3714
+ if (/game_design_document\.md/i.test(normalized))
3715
+ score += 85;
3716
+ if (/\.(js|ts|tsx|jsx|mjs|cjs)$/i.test(normalized))
3717
+ score += 70;
3718
+ if (normalized.endsWith('index.html'))
3719
+ score += 50;
3720
+ if (/production_roadmap|project_status_report|status_report/i.test(normalized))
3721
+ score += 5;
3722
+ if (normalized.endsWith('readme.md'))
3723
+ score += normalized.includes('vigthoria-dominion') ? 25 : 10;
3724
+ return score;
3725
+ }
3726
+ isLowTrustDocPath(filePath) {
3727
+ const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
3728
+ return normalized.endsWith('readme.md')
3729
+ || /production_roadmap|project_status_report|status_report|roadmap\.md/i.test(normalized);
3730
+ }
3731
+ extractCodeSignalsFromEvidence(filesRead, liveToolEvidence = []) {
3732
+ const signals = new Set();
3733
+ const ingest = (filePath, excerpt) => {
3734
+ const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
3735
+ const text = String(excerpt || '');
3736
+ const lower = text.toLowerCase();
3737
+ if (normalized.endsWith('package.json')) {
3738
+ const match = text.match(/"description"\s*:\s*"([^"]+)"/i);
3739
+ if (match?.[1])
3740
+ signals.add(`package.json: ${match[1]}`);
3741
+ if (/rts|real-time strategy|command & conquer/i.test(text)) {
3742
+ signals.add('package.json identifies a real-time strategy game project.');
3743
+ }
3744
+ }
3745
+ if (/(^|\/)game\.js$/i.test(normalized) || /\/src\/game\.js$/i.test(normalized)) {
3746
+ if (/rts|real-time strategy|command & conquer|dune 2000|3d rts/i.test(lower)) {
3747
+ signals.add('game.js entry identifies a 3D RTS engine (Command & Conquer / Dune 2000 style).');
3748
+ }
3749
+ }
3750
+ if (/\/src\/factions\//i.test(normalized) || /factionmodels\.js/i.test(normalized)) {
3751
+ const factions = ['Iron Dominion', 'Nova Collective', 'Aqua Tide', 'Swarm Hive']
3752
+ .filter((name) => lower.includes(name.toLowerCase()));
3753
+ if (factions.length >= 2) {
3754
+ signals.add(`Faction code references: ${factions.join(', ')} (4-faction RTS).`);
3755
+ }
3756
+ }
3757
+ if (/vigthoria-dominion-win/i.test(normalized) || /dist-desktop|electron/i.test(lower)) {
3758
+ signals.add('Desktop/Electron build artifacts are present (full desktop RTS distribution).');
3759
+ }
3760
+ };
3761
+ for (const entry of filesRead)
3762
+ ingest(entry.path, entry.excerpt);
3763
+ for (const entry of liveToolEvidence) {
3764
+ if (entry?.success === false)
3765
+ continue;
3766
+ ingest(String(entry.target || entry.arguments?.path || ''), String(entry.output || ''));
3767
+ }
3768
+ return [...signals];
3769
+ }
3770
+ classifyGameGenreFromText(text) {
3771
+ const lower = String(text || '').toLowerCase();
3772
+ if (/card game|deck-building|dominion is a deck|treasure cards?|victory points?|buy phase|action phase/i.test(lower)) {
3773
+ return 'card/deck-building game';
3774
+ }
3775
+ if (/rts|real-time strategy|command & conquer|dune 2000|3d rts|build bases|gather resources|train units/i.test(lower)) {
3776
+ return '3D real-time strategy (RTS) game';
3777
+ }
3778
+ if (/turn-based strategy|4x strategy/i.test(lower)) {
3779
+ return 'turn-based strategy game';
3780
+ }
3781
+ if (/platformer|roguelike|puzzle game/i.test(lower)) {
3782
+ const match = lower.match(/(platformer|roguelike|puzzle game)/i);
3783
+ return match?.[1] || null;
3784
+ }
3785
+ return null;
3786
+ }
3787
+ extractReadmeGenreClaims(filesRead, liveToolEvidence = []) {
3788
+ const claims = [];
3789
+ const ingest = (filePath, excerpt) => {
3790
+ const normalized = String(filePath || '').replace(/\\/g, '/');
3791
+ if (!/readme\.md$/i.test(normalized))
3792
+ return;
3793
+ const genre = this.classifyGameGenreFromText(excerpt);
3794
+ if (!genre)
3795
+ return;
3796
+ const lines = String(excerpt || '').split('\n').map((line) => line.trim()).filter(Boolean);
3797
+ const quoteLine = lines.find((line) => /card game|deck|rts|real-time strategy|strategy game|browser game|3d/i.test(line))
3798
+ || lines.slice(0, 6).join(' ');
3799
+ claims.push({
3800
+ path: normalized,
3801
+ genre,
3802
+ quote: quoteLine.slice(0, 220),
3803
+ });
3804
+ };
3805
+ for (const entry of filesRead)
3806
+ ingest(entry.path, entry.excerpt);
3807
+ for (const entry of liveToolEvidence) {
3808
+ if (entry?.success === false)
3809
+ continue;
3810
+ ingest(String(entry.target || entry.arguments?.path || ''), String(entry.output || ''));
3811
+ }
3812
+ return claims;
3813
+ }
3814
+ extractCodeGenreClaims(filesRead, liveToolEvidence = []) {
3815
+ const sources = [];
3816
+ const factions = new Set();
3817
+ let genre = null;
3818
+ const ingest = (filePath, excerpt) => {
3819
+ const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
3820
+ const text = String(excerpt || '');
3821
+ const detected = this.classifyGameGenreFromText(text);
3822
+ const isCodeFile = normalized.endsWith('package.json')
3823
+ || /(^|\/)game\.js$/i.test(normalized)
3824
+ || /\/src\//i.test(normalized);
3825
+ if (detected && isCodeFile) {
3826
+ genre = genre || detected;
3827
+ sources.push(normalized);
3828
+ }
3829
+ if (/\/src\/factions\//i.test(normalized) || /factionmodels\.js/i.test(normalized)) {
3830
+ for (const name of ['Iron Dominion', 'Nova Collective', 'Aqua Tide', 'Swarm Hive']) {
3831
+ if (text.toLowerCase().includes(name.toLowerCase()))
3832
+ factions.add(name);
3833
+ }
3834
+ }
3835
+ };
3836
+ for (const entry of filesRead)
3837
+ ingest(entry.path, entry.excerpt);
3838
+ for (const entry of liveToolEvidence) {
3839
+ if (entry?.success === false)
3840
+ continue;
3841
+ ingest(String(entry.target || entry.arguments?.path || ''), String(entry.output || ''));
3842
+ }
3843
+ return { genre, sources: [...new Set(sources)], factions: [...factions] };
3844
+ }
3845
+ buildReadmeCodeConsistencySection(filesRead, liveToolEvidence = []) {
3846
+ const readmeClaims = this.extractReadmeGenreClaims(filesRead, liveToolEvidence);
3847
+ const codeClaims = this.extractCodeGenreClaims(filesRead, liveToolEvidence);
3848
+ if (readmeClaims.length === 0 && !codeClaims.genre) {
3849
+ return null;
3850
+ }
3851
+ const lines = ['## README vs code — consistency check'];
3852
+ if (codeClaims.genre) {
3853
+ lines.push('', '**What the game actually is (from code):**');
3854
+ lines.push(`- Genre: **${codeClaims.genre}**`);
3855
+ if (codeClaims.factions.length >= 2) {
3856
+ lines.push(`- Factions in source: ${codeClaims.factions.join(', ')}`);
3857
+ }
3858
+ if (codeClaims.sources.length > 0) {
3859
+ lines.push(`- Evidence: ${codeClaims.sources.slice(0, 4).map((entry) => `\`${entry}\``).join(', ')}`);
3860
+ }
3861
+ }
3862
+ else {
3863
+ lines.push('', '**What the game actually is (from code):** _Not determined — core code files were not read yet._');
3864
+ }
3865
+ if (readmeClaims.length > 0) {
3866
+ lines.push('', '**What README says:**');
3867
+ for (const claim of readmeClaims.slice(0, 4)) {
3868
+ lines.push(`- \`${claim.path}\` → **${claim.genre}**`);
3869
+ if (claim.quote)
3870
+ lines.push(` > "${claim.quote}"`);
3871
+ }
3872
+ }
3873
+ const readmeGenres = new Set(readmeClaims.map((entry) => entry.genre));
3874
+ const hasConflict = codeClaims.genre
3875
+ && readmeClaims.some((entry) => entry.genre !== codeClaims.genre);
3876
+ if (hasConflict && codeClaims.genre) {
3877
+ lines.push('', '**Status: INCONSISTENT** — README documentation contradicts the codebase.', '', `The project code identifies this as a **${codeClaims.genre}**, but README describes it differently (${[...readmeGenres].join('; ')}).`, 'When reporting to the user, state the **code truth first**, then explicitly call out that README is wrong or stale and should be corrected.');
3878
+ }
3879
+ else if (codeClaims.genre && readmeClaims.length > 0) {
3880
+ lines.push('', '**Status: CONSISTENT** — README and code agree on the project type.');
3881
+ }
3882
+ else if (readmeClaims.length > 0 && !codeClaims.genre) {
3883
+ lines.push('', '**Status: UNVERIFIED** — README was read but core code was not inspected.', 'Do not present README claims as fact until package.json, game.js, and src/ are read.');
3884
+ }
3885
+ return lines.join('\n');
3886
+ }
3887
+ detectDocReliabilityWarnings(filesRead, liveToolEvidence = []) {
3888
+ const warnings = [];
3889
+ let subfolderCardGameReadme = false;
3890
+ let codeSaysRts = false;
3891
+ let readRootPlanningDocs = false;
3892
+ let readCoreCode = false;
3893
+ const inspect = (filePath, excerpt) => {
3894
+ const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
3895
+ const lower = String(excerpt || '').toLowerCase();
3896
+ if (/production_roadmap|project_status_report|status_report/i.test(normalized)) {
3897
+ readRootPlanningDocs = true;
3898
+ }
3899
+ if (/vigthoria-dominion\/readme\.md$/i.test(normalized) && /card game|deck-building|dominion is a deck/i.test(lower)) {
3900
+ subfolderCardGameReadme = true;
3901
+ }
3902
+ if (/readme\.md$/i.test(normalized) && /card game|deck-building|dominion is a deck/i.test(lower)) {
3903
+ subfolderCardGameReadme = true;
3904
+ }
3905
+ if (/(^|\/)game\.js$/i.test(normalized) || /\/src\/game\.js$/i.test(normalized) || normalized.endsWith('package.json')) {
3906
+ if (/rts|real-time strategy|command & conquer|3d rts/i.test(lower)) {
3907
+ codeSaysRts = true;
3908
+ readCoreCode = true;
3909
+ }
3910
+ }
3911
+ if (/\/src\//i.test(normalized) && /\.(js|ts)$/i.test(normalized)) {
3912
+ readCoreCode = true;
3913
+ }
3914
+ };
3915
+ for (const entry of filesRead)
3916
+ inspect(entry.path, entry.excerpt);
3917
+ for (const entry of liveToolEvidence) {
3918
+ if (entry?.success === false)
3919
+ continue;
3920
+ inspect(String(entry.target || entry.arguments?.path || ''), String(entry.output || ''));
3921
+ }
3922
+ if (subfolderCardGameReadme && codeSaysRts) {
3923
+ warnings.push('README describes a card/deck-building game, but code (`game.js` / `package.json`) describes a 3D RTS. Report the RTS truth first, then tell the user README is inconsistent and should be fixed.');
3924
+ }
3925
+ if (readRootPlanningDocs && !readCoreCode) {
3926
+ warnings.push('The agent read planning/status markdown at the workspace root but did not read core game code under `Vigthoria-dominion/src/` or `package.json`. Any genre/feature summary from those docs may be wrong.');
3927
+ }
3928
+ if (!readCoreCode) {
3929
+ warnings.push('Core source files (`package.json`, entry `game.js`, and key files under `src/`) were not read. The overview may be incomplete until those files are inspected.');
3930
+ }
3931
+ return warnings;
3932
+ }
2876
3933
  /**
2877
3934
  * Build a human-readable answer from the tool results in a V3 event
2878
3935
  * stream when the server didn't emit a proper complete/message event.
2879
3936
  */
2880
- synthesizeAnswerFromV3Events(events) {
3937
+ synthesizeAnswerFromV3Events(events, liveToolEvidence = []) {
2881
3938
  const toolResults = [];
3939
+ const failedPaths = [];
2882
3940
  const filesRead = [];
2883
3941
  const filesWritten = [];
3942
+ const directoriesListed = [];
3943
+ const searches = [];
2884
3944
  const assistantFragments = [];
3945
+ const pendingToolCalls = [];
3946
+ const ingestToolEvidence = (name, args, output, success) => {
3947
+ const target = String(args.path || args.file_path || args.file || args.target || '').trim();
3948
+ const cleanOutput = sanitizeUserFacingPathText(String(output || '').trim());
3949
+ if (!success || !cleanOutput) {
3950
+ if (!success && cleanOutput) {
3951
+ const failPath = target || name;
3952
+ failedPaths.push({
3953
+ path: sanitizeUserFacingPathText(failPath),
3954
+ detail: cleanOutput.split('\n').find(Boolean)?.slice(0, 160) || cleanOutput.slice(0, 160),
3955
+ });
3956
+ toolResults.push(`[${name}] ${cleanOutput.slice(0, 400)}`);
3957
+ }
3958
+ return;
3959
+ }
3960
+ if (name === 'read_file') {
3961
+ const filePath = target || 'unknown file';
3962
+ const excerpt = cleanOutput.length > 4000 ? `${cleanOutput.slice(0, 4000)}\n\n[...truncated...]` : cleanOutput;
3963
+ filesRead.push({ path: sanitizeUserFacingPathText(filePath), excerpt });
3964
+ return;
3965
+ }
3966
+ if (name === 'list_directory') {
3967
+ const excerpt = cleanOutput.length > 1200 ? `${cleanOutput.slice(0, 1200)}\n[...truncated...]` : cleanOutput;
3968
+ directoriesListed.push({ path: sanitizeUserFacingPathText(target || '.'), excerpt });
3969
+ return;
3970
+ }
3971
+ if (name === 'search_files') {
3972
+ const pattern = String(args.pattern || args.query || '').trim();
3973
+ const searchPath = String(args.path || '.').trim();
3974
+ searches.push(`${pattern || 'search'} in ${sanitizeUserFacingPathText(searchPath)}`);
3975
+ if (cleanOutput.length > 0) {
3976
+ toolResults.push(`[search_files] ${cleanOutput.slice(0, 600)}`);
3977
+ }
3978
+ return;
3979
+ }
3980
+ if (name === 'write_file' || name === 'create_file' || name === 'edit_file') {
3981
+ if (target)
3982
+ filesWritten.push(sanitizeUserFacingPathText(target));
3983
+ return;
3984
+ }
3985
+ toolResults.push(`[${name}] ${cleanOutput.slice(0, 600)}`);
3986
+ };
2885
3987
  for (const event of events) {
2886
3988
  if (!event)
2887
3989
  continue;
2888
- if (event.type === 'tool_result' && event.success && typeof event.output === 'string') {
2889
- const name = event.name || 'unknown_tool';
2890
- if (name === 'read_file' && typeof event.target === 'string') {
2891
- filesRead.push(sanitizeUserFacingPathText(event.target));
2892
- }
2893
- else if ((name === 'write_file' || name === 'create_file') && typeof event.target === 'string') {
2894
- filesWritten.push(sanitizeUserFacingPathText(event.target));
2895
- }
2896
- else {
2897
- // Keep last ~300 chars of output for context
2898
- const excerpt = event.output.length > 300 ? event.output.slice(-300) : event.output;
2899
- toolResults.push(`[${name}] ${sanitizeUserFacingPathText(excerpt)}`);
2900
- }
3990
+ if (event.type === 'tool_call') {
3991
+ pendingToolCalls.push({
3992
+ name: String(event.name || event.tool || 'unknown_tool'),
3993
+ args: event.arguments && typeof event.arguments === 'object' ? event.arguments : {},
3994
+ });
3995
+ continue;
3996
+ }
3997
+ if (event.type === 'tool_result') {
3998
+ const success = event.success !== false;
3999
+ const output = typeof event.output === 'string'
4000
+ ? event.output
4001
+ : typeof event.result === 'string'
4002
+ ? event.result
4003
+ : '';
4004
+ const name = String(event.name || event.tool || 'unknown_tool');
4005
+ const callIndex = pendingToolCalls.findIndex((call) => call.name === name);
4006
+ const call = callIndex >= 0 ? pendingToolCalls.splice(callIndex, 1)[0] : pendingToolCalls.shift();
4007
+ ingestToolEvidence(name, call?.args || {}, output, success);
2901
4008
  }
2902
4009
  if (event.type === 'assistant' && typeof event.content === 'string' && event.content.trim()) {
2903
- assistantFragments.push(sanitizeUserFacingPathText(event.content.trim()));
4010
+ const fragment = sanitizeUserFacingPathText(event.content.trim());
4011
+ if (!this.isV3StreamStatusMessage(fragment)) {
4012
+ assistantFragments.push(fragment);
4013
+ }
2904
4014
  }
2905
4015
  // Some servers emit 'text' events for incremental assistant text
2906
4016
  if (event.type === 'text' && typeof event.content === 'string' && event.content.trim()) {
2907
- assistantFragments.push(sanitizeUserFacingPathText(event.content.trim()));
4017
+ const fragment = sanitizeUserFacingPathText(event.content.trim());
4018
+ if (!this.isV3StreamStatusMessage(fragment)) {
4019
+ assistantFragments.push(fragment);
4020
+ }
4021
+ }
4022
+ if (event.type === 'message' && typeof event.content === 'string' && event.content.trim()) {
4023
+ const fragment = sanitizeUserFacingPathText(event.content.trim());
4024
+ if (!this.isV3StreamStatusMessage(fragment)) {
4025
+ assistantFragments.push(fragment);
4026
+ }
2908
4027
  }
2909
4028
  // Some servers emit content_block_delta for streamed text
2910
4029
  if (event.type === 'content_block_delta' && typeof event.delta?.text === 'string' && event.delta.text.trim()) {
2911
4030
  assistantFragments.push(sanitizeUserFacingPathText(event.delta.text.trim()));
2912
4031
  }
2913
4032
  }
2914
- // Concatenate ALL assistant text fragments in order — keeps full
2915
- // multi-turn reasoning instead of only the last fragment.
4033
+ for (const entry of liveToolEvidence) {
4034
+ if (!entry || typeof entry !== 'object')
4035
+ continue;
4036
+ ingestToolEvidence(String(entry.name || entry.tool || 'unknown_tool'), entry.arguments && typeof entry.arguments === 'object' ? entry.arguments : { path: entry.target || entry.path || '' }, String(entry.output || ''), entry.success !== false);
4037
+ }
4038
+ // Prefer grounded tool evidence over streamed status chatter.
4039
+ const hasEvidence = directoriesListed.length > 0
4040
+ || filesRead.length > 0
4041
+ || searches.length > 0
4042
+ || filesWritten.length > 0
4043
+ || toolResults.length > 0
4044
+ || failedPaths.length > 0;
4045
+ if (hasEvidence) {
4046
+ const sections = [];
4047
+ sections.push('## Workspace analysis (from local file inspection)');
4048
+ sections.push('This report is rebuilt from files the agent actually read. **Code and package manifests outrank README/planning docs**, which may be stale or AI-generated.');
4049
+ const codeSignals = this.extractCodeSignalsFromEvidence(filesRead, liveToolEvidence);
4050
+ const consistencySection = this.buildReadmeCodeConsistencySection(filesRead, liveToolEvidence);
4051
+ if (consistencySection) {
4052
+ sections.unshift(consistencySection);
4053
+ }
4054
+ if (codeSignals.length > 0) {
4055
+ sections.push(['## What the code says (trusted)', ...codeSignals.map((line) => `- ${line}`)].join('\n'));
4056
+ if (!consistencySection && codeSignals.some((line) => /rts|real-time strategy|3d rts|4-faction|desktop\/electron/i.test(line))) {
4057
+ sections.unshift([
4058
+ '## Executive summary',
4059
+ 'Vigthoria Dominion is a **3D real-time strategy game** (Command & Conquer / Dune 2000 style) with **four factions**, based on code evidence from your local workspace — not from README/planning docs, which may be wrong.',
4060
+ 'The project includes browser/Three.js source (`Vigthoria-dominion/`) and may include a desktop Electron build (`vigthoria-dominion-win/`).',
4061
+ ].join('\n'));
4062
+ }
4063
+ }
4064
+ else if (consistencySection && /INCONSISTENT/i.test(consistencySection)) {
4065
+ sections.unshift([
4066
+ '## Executive summary',
4067
+ 'The agent found that **README documentation does not match the codebase**. See the consistency check below — the code defines what this game actually is.',
4068
+ ].join('\n'));
4069
+ }
4070
+ const docWarnings = this.detectDocReliabilityWarnings(filesRead, liveToolEvidence);
4071
+ if (docWarnings.length > 0) {
4072
+ sections.push(['## Documentation reliability warnings', ...docWarnings.map((line) => `- ${line}`)].join('\n'));
4073
+ }
4074
+ if (directoriesListed.length > 0) {
4075
+ const uniqueDirs = new Map();
4076
+ for (const entry of directoriesListed) {
4077
+ if (!uniqueDirs.has(entry.path))
4078
+ uniqueDirs.set(entry.path, entry.excerpt);
4079
+ }
4080
+ const dirLines = [...uniqueDirs.entries()].slice(0, 8).map(([dirPath, excerpt]) => {
4081
+ const preview = excerpt.split('\n').filter(Boolean).slice(0, 12).join('\n');
4082
+ return `### ${dirPath}\n${preview}`;
4083
+ });
4084
+ sections.push(['## Directories inspected', ...dirLines].join('\n\n'));
4085
+ }
4086
+ if (filesRead.length > 0) {
4087
+ const uniqueFiles = new Map();
4088
+ for (const entry of filesRead) {
4089
+ if (!uniqueFiles.has(entry.path))
4090
+ uniqueFiles.set(entry.path, entry.excerpt);
4091
+ }
4092
+ const sortedFiles = [...uniqueFiles.entries()].sort((a, b) => this.scoreEvidenceFilePath(b[0]) - this.scoreEvidenceFilePath(a[0]));
4093
+ const fileLines = sortedFiles.slice(0, 8).map(([filePath, excerpt]) => {
4094
+ const tag = this.isLowTrustDocPath(filePath) ? ' _(low-trust doc)_' : '';
4095
+ return `### ${filePath}${tag}\n${excerpt}`;
4096
+ });
4097
+ sections.push(['## Files read (code-first order)', ...fileLines].join('\n\n'));
4098
+ }
4099
+ if (searches.length > 0) {
4100
+ sections.push(`## Searches run\n${Array.from(new Set(searches)).slice(0, 8).map((entry) => `- ${entry}`).join('\n')}`);
4101
+ }
4102
+ if (filesWritten.length > 0) {
4103
+ sections.push(`## Files written\n${Array.from(new Set(filesWritten)).slice(0, 12).map((entry) => `- ${entry}`).join('\n')}`);
4104
+ }
4105
+ if (failedPaths.length > 0) {
4106
+ const uniqueFails = new Map();
4107
+ for (const entry of failedPaths) {
4108
+ if (!uniqueFails.has(entry.path))
4109
+ uniqueFails.set(entry.path, entry.detail);
4110
+ }
4111
+ const failLines = [...uniqueFails.entries()].slice(0, 12).map(([failPath, detail]) => `- \`${failPath}\`${detail ? ` — ${detail}` : ''}`);
4112
+ sections.push([
4113
+ '## Paths not found (exploration misses)',
4114
+ ...failLines,
4115
+ '',
4116
+ '_These paths were guessed by the agent and do not exist at that location. Check the directories inspected above for the correct layout (often under `src/`)._',
4117
+ ].join('\n'));
4118
+ }
4119
+ if (toolResults.length > 0) {
4120
+ sections.push(`## Additional tool evidence\n${toolResults.slice(-5).join('\n\n')}`);
4121
+ }
4122
+ return sections.join('\n\n');
4123
+ }
4124
+ // Concatenate substantive assistant text fragments in order.
2916
4125
  const fullAssistantText = assistantFragments.join('\n\n').trim();
2917
- if (fullAssistantText.length > 20) {
4126
+ if (fullAssistantText.length > 80 && !this.isGenericV3AgentSummary(fullAssistantText)) {
2918
4127
  return fullAssistantText;
2919
4128
  }
2920
- // Otherwise build a summary from tool evidence
2921
- const sections = [];
2922
- if (filesRead.length > 0) {
2923
- sections.push(`Files inspected: ${filesRead.join(', ')}`);
2924
- }
2925
- if (filesWritten.length > 0) {
2926
- sections.push(`Files written: ${filesWritten.join(', ')}`);
2927
- }
2928
- if (toolResults.length > 0) {
2929
- sections.push(toolResults.slice(-5).join('\n'));
2930
- }
2931
- return sections.length > 0
2932
- ? sections.join('\n\n')
2933
- : '';
4129
+ return '';
2934
4130
  }
2935
4131
  sanitizeV3AgentEventForUser(event) {
2936
4132
  const sanitizeValue = (value) => {
@@ -3013,17 +4209,42 @@ document.addEventListener('DOMContentLoaded', () => {
3013
4209
  break;
3014
4210
  }
3015
4211
  buffer += decoder.decode(value, { stream: true });
3016
- const lines = buffer.split('\n');
3017
- buffer = lines.pop() || '';
3018
- for (const line of lines) {
3019
- if (!line.startsWith('data: ')) {
3020
- continue;
4212
+ const frames = buffer.split(/\r?\n\r?\n/);
4213
+ buffer = frames.pop() || '';
4214
+ for (const frame of frames) {
4215
+ const lines = frame.split(/\r?\n/);
4216
+ const dataLines = [];
4217
+ let explicitEventType = null;
4218
+ for (const rawLine of lines) {
4219
+ const line = rawLine.trimEnd();
4220
+ if (!line || line.startsWith(':')) {
4221
+ continue;
4222
+ }
4223
+ if (line.startsWith('event:')) {
4224
+ explicitEventType = line.slice(6).trim() || null;
4225
+ continue;
4226
+ }
4227
+ if (line.startsWith('data:')) {
4228
+ dataLines.push(line.slice(5).trimStart());
4229
+ }
3021
4230
  }
3022
- const payload = line.slice(6).trim();
4231
+ const payload = dataLines.join('\n').trim();
3023
4232
  if (!payload || payload === '[DONE]') {
3024
4233
  continue;
3025
4234
  }
3026
- const event = JSON.parse(payload);
4235
+ let event;
4236
+ try {
4237
+ event = JSON.parse(payload);
4238
+ }
4239
+ catch {
4240
+ event = {
4241
+ type: explicitEventType || 'message',
4242
+ content: payload,
4243
+ };
4244
+ }
4245
+ if (explicitEventType && (!event.type || event.type === 'message')) {
4246
+ event.type = explicitEventType;
4247
+ }
3027
4248
  const userEvent = this.sanitizeV3AgentEventForUser(event);
3028
4249
  events.push(userEvent);
3029
4250
  if (!contextId && typeof event.context_id === 'string' && event.context_id.trim()) {
@@ -3034,10 +4255,9 @@ document.addEventListener('DOMContentLoaded', () => {
3034
4255
  }
3035
4256
  this.captureV3AgentStreamMutation(event, streamedFiles, serverWorkspaceRoot);
3036
4257
  this.applyV3AgentStreamEventToWorkspace(event, context, serverWorkspaceRoot);
3037
- // Empty workspace guard: if the remote agent lists its root
3038
- // and finds nothing while our local workspace has files, the
3039
- // workspace was not hydrated. Abort early with a clear error
3040
- // instead of letting the agent spin on an empty directory.
4258
+ // Empty workspace soft guard: the first list_directory result can be
4259
+ // transiently empty before remote workspace hydration catches up.
4260
+ // Avoid hard-failing here; let the stream continue.
3041
4261
  if (event.type === 'tool_result'
3042
4262
  && event.name === 'list_directory'
3043
4263
  && event.success === true
@@ -3052,9 +4272,7 @@ document.addEventListener('DOMContentLoaded', () => {
3052
4272
  const localPath = this.resolveAgentTargetPath(context);
3053
4273
  const localHasFiles = localPath && fs.existsSync(localPath) && this.hasAgentWorkspaceOutput({ ...context, projectPath: localPath, targetPath: localPath });
3054
4274
  if (localHasFiles) {
3055
- throw new Error('Remote workspace is empty the V3 server did not receive your project files. '
3056
- + 'Your local workspace has files but the remote agent sees an empty directory. '
3057
- + 'This is a workspace sync failure. Falling back to local agent loop.');
4275
+ this.logger?.debug?.('V3 remote workspace initially empty; waiting for hydration instead of aborting.');
3058
4276
  }
3059
4277
  }
3060
4278
  }
@@ -3066,6 +4284,43 @@ document.addEventListener('DOMContentLoaded', () => {
3066
4284
  // Ignore UI callback failures; never break the agent stream for them.
3067
4285
  }
3068
4286
  }
4287
+ if (event.type === 'client_tool_request') {
4288
+ const activeContextId = String(event.context_id || contextId || context.contextId || '').trim();
4289
+ const callId = String(event.call_id || '').trim();
4290
+ const handler = context.onClientToolExecute;
4291
+ let toolResult = {
4292
+ success: false,
4293
+ output: '',
4294
+ error: 'No local tool handler configured',
4295
+ };
4296
+ if (typeof handler === 'function' && activeContextId && callId) {
4297
+ try {
4298
+ toolResult = await handler(event);
4299
+ }
4300
+ catch (error) {
4301
+ toolResult = {
4302
+ success: false,
4303
+ output: '',
4304
+ error: sanitizeUserFacingErrorText(error?.message || 'Local tool execution failed'),
4305
+ };
4306
+ }
4307
+ }
4308
+ if (activeContextId && callId) {
4309
+ await this.submitClientToolResult(activeContextId, callId, toolResult, context.v3BackendUrl);
4310
+ }
4311
+ continue;
4312
+ }
4313
+ if (event.type === 'context' && typeof context.onWorkspaceContext === 'function') {
4314
+ try {
4315
+ await context.onWorkspaceContext({
4316
+ contextId: String(event.context_id || contextId || '').trim(),
4317
+ serverWorkspaceRoot: String(event.workspace_root || serverWorkspaceRoot || '').trim(),
4318
+ });
4319
+ }
4320
+ catch {
4321
+ // Ignore workspace bind callback failures.
4322
+ }
4323
+ }
3069
4324
  if (event.type === 'error') {
3070
4325
  if (event.checkpointed && event.task_id) {
3071
4326
  // Agent checkpointed — return data so caller can auto-continue
@@ -3095,6 +4350,19 @@ document.addEventListener('DOMContentLoaded', () => {
3095
4350
  if (event.type === 'complete' || event.type === 'message') {
3096
4351
  final = event;
3097
4352
  }
4353
+ // Exit stream early on complete — agent is done; server-side teardown
4354
+ // can hold the connection open for many seconds otherwise.
4355
+ if (event.type === 'complete') {
4356
+ reader.cancel().catch(() => { });
4357
+ return {
4358
+ task_id: events.find((entry) => entry && entry.task_id)?.task_id || null,
4359
+ context_id: contextId,
4360
+ result: final,
4361
+ events,
4362
+ files: streamedFiles,
4363
+ serverWorkspaceRoot: serverWorkspaceRoot || null,
4364
+ };
4365
+ }
3098
4366
  }
3099
4367
  }
3100
4368
  return {
@@ -3106,6 +4374,36 @@ document.addEventListener('DOMContentLoaded', () => {
3106
4374
  serverWorkspaceRoot: serverWorkspaceRoot || null,
3107
4375
  };
3108
4376
  }
4377
+ async submitClientToolResult(contextId, callId, result, backendUrl) {
4378
+ const trimmedContextId = String(contextId || '').trim();
4379
+ const trimmedCallId = String(callId || '').trim();
4380
+ if (!trimmedContextId || !trimmedCallId) {
4381
+ return;
4382
+ }
4383
+ const baseUrl = String(backendUrl || this.getV3AgentBaseUrls()[0] || '').replace(/\/$/, '');
4384
+ if (!baseUrl) {
4385
+ return;
4386
+ }
4387
+ const headers = await this.getV3AgentHeaders();
4388
+ const endpoint = /127\.0\.0\.1:8030|localhost:8030/.test(baseUrl)
4389
+ ? `${baseUrl}/api/agent/client-tool-result`
4390
+ : `${baseUrl}/api/v3-agent/client-tool-result`;
4391
+ const response = await fetch(endpoint, {
4392
+ method: 'POST',
4393
+ headers: { ...headers, 'Content-Type': 'application/json' },
4394
+ body: JSON.stringify({
4395
+ context_id: trimmedContextId,
4396
+ call_id: trimmedCallId,
4397
+ success: result.success === true,
4398
+ output: String(result.output || ''),
4399
+ error: String(result.error || ''),
4400
+ }),
4401
+ });
4402
+ if (!response.ok) {
4403
+ const errorText = await response.text().catch(() => '');
4404
+ throw new Error(`Client tool result rejected (${response.status}): ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
4405
+ }
4406
+ }
3109
4407
  async runV3AgentWorkflow(message, context = {}) {
3110
4408
  const executionContext = await this.bindExecutionContext(context);
3111
4409
  const requestedTimeoutMs = Number(executionContext.agentTimeoutMs ?? DEFAULT_V3_AGENT_TIMEOUT_MS);
@@ -3135,7 +4433,7 @@ document.addEventListener('DOMContentLoaded', () => {
3135
4433
  legacyFallbackAllowed: true,
3136
4434
  })
3137
4435
  : executionContext;
3138
- const requestBody = {
4436
+ const buildRequestBody = (contextIdOverride) => ({
3139
4437
  request: message,
3140
4438
  model: resolvedModel,
3141
4439
  preferred_model: resolvedModel,
@@ -3145,119 +4443,154 @@ document.addEventListener('DOMContentLoaded', () => {
3145
4443
  context: useRelaxedAttempt
3146
4444
  ? this.buildMinimalV3AgentContext(requestExecutionContext)
3147
4445
  : this.buildV3AgentContext(requestExecutionContext),
3148
- context_id: requestExecutionContext.contextId,
4446
+ context_id: contextIdOverride ?? requestExecutionContext.contextId,
3149
4447
  mcp_context_id: useRelaxedAttempt ? null : requestExecutionContext.mcpContextId || null,
3150
4448
  stream: true,
3151
- };
4449
+ });
3152
4450
  for (const baseUrl of this.getV3AgentBaseUrls(preferLocalV3)) {
3153
- const controller = new AbortController();
3154
- const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
3155
- try {
3156
- const response = await this.executeV3AgentRunRequest(baseUrl, requestBody, requestExecutionContext, controller.signal);
3157
- if (!response.ok) {
3158
- const errorText = await response.text().catch(() => '');
3159
- throw new Error(`V3 agent ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
3160
- }
3161
- const data = await this.collectV3AgentStream(response, requestExecutionContext);
3162
- // Auto-continuation: if the agent checkpointed (budget exceeded), continue automatically
3163
- if (data.checkpointed && data.checkpointed_task_id) {
3164
- const maxContinuations = 10;
3165
- let continuationData = data;
3166
- let continuations = 0;
3167
- while (continuationData.checkpointed && continuationData.checkpointed_task_id && continuations < maxContinuations) {
3168
- continuations++;
3169
- if (typeof requestExecutionContext.onStreamEvent === 'function') {
4451
+ let contextIdOverride;
4452
+ const authPreflight = await this.runV3AgentAuthPreflight(baseUrl, buildRequestBody(), requestExecutionContext);
4453
+ if (!authPreflight.ok) {
4454
+ errors.push(`V3 auth preflight ${authPreflight.status} [${authPreflight.endpoint}]: ${authPreflight.reason || 'Unauthorized'}`);
4455
+ continue;
4456
+ }
4457
+ for (let contextRetry = 0; contextRetry < 2; contextRetry += 1) {
4458
+ const requestBody = buildRequestBody(contextIdOverride);
4459
+ const controller = new AbortController();
4460
+ const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
4461
+ try {
4462
+ const response = await this.executeV3AgentRunRequest(baseUrl, requestBody, requestExecutionContext, controller.signal);
4463
+ if (!response.ok) {
4464
+ const errorText = await response.text().catch(() => '');
4465
+ const sanitized = sanitizeUserFacingErrorText(errorText).slice(0, 200);
4466
+ const isContextCollision = response.status === 409 && /already in progress/i.test(errorText);
4467
+ const isTransientWorkspaceHydration = /remote workspace is empty|workspace sync failure/i.test(errorText);
4468
+ if ((isContextCollision || isTransientWorkspaceHydration) && contextRetry === 0) {
4469
+ contextIdOverride = `${requestExecutionContext.contextId || 'vig'}-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4470
+ this.logger?.debug?.(`V3 transient agent error on ${baseUrl}; retrying with fresh context id.`);
4471
+ continue;
4472
+ }
4473
+ throw new Error(`V3 agent ${response.status}: ${sanitized}`);
4474
+ }
4475
+ const data = await this.collectV3AgentStream(response, {
4476
+ ...requestExecutionContext,
4477
+ v3BackendUrl: baseUrl,
4478
+ });
4479
+ // Auto-continuation: if the agent checkpointed (budget exceeded), continue automatically
4480
+ if (data.checkpointed && data.checkpointed_task_id) {
4481
+ const maxContinuations = 10;
4482
+ let continuationData = data;
4483
+ let continuations = 0;
4484
+ while (continuationData.checkpointed && continuationData.checkpointed_task_id && continuations < maxContinuations) {
4485
+ continuations++;
4486
+ if (typeof requestExecutionContext.onStreamEvent === 'function') {
4487
+ try {
4488
+ requestExecutionContext.onStreamEvent({
4489
+ type: 'message',
4490
+ content: `Auto-continuing task (phase ${continuations + 1})...`,
4491
+ });
4492
+ }
4493
+ catch { /* ignore */ }
4494
+ }
4495
+ const continueBody = {
4496
+ task_id: continuationData.checkpointed_task_id,
4497
+ context: requestBody.context,
4498
+ context_id: requestBody.context_id,
4499
+ mcp_context_id: requestBody.mcp_context_id,
4500
+ stream: true,
4501
+ };
4502
+ const continueController = new AbortController();
4503
+ const continueTimeoutId = timeoutMs > 0 ? setTimeout(() => continueController.abort(), timeoutMs) : null;
3170
4504
  try {
3171
- requestExecutionContext.onStreamEvent({
3172
- type: 'message',
3173
- content: `Auto-continuing task (phase ${continuations + 1})...`,
4505
+ const continueHeaders = await this.getV3AgentHeaders();
4506
+ const continueResponse = await fetch(this.getV3AgentContinueUrl(baseUrl), {
4507
+ method: 'POST',
4508
+ headers: { ...continueHeaders, 'Content-Type': 'application/json' },
4509
+ body: JSON.stringify(continueBody),
4510
+ signal: continueController.signal,
4511
+ });
4512
+ if (!continueResponse.ok) {
4513
+ break; // Fall through to normal completion with partial data
4514
+ }
4515
+ continuationData = await this.collectV3AgentStream(continueResponse, {
4516
+ ...requestExecutionContext,
4517
+ v3BackendUrl: baseUrl,
3174
4518
  });
3175
4519
  }
3176
- catch { /* ignore */ }
3177
- }
3178
- const continueBody = {
3179
- task_id: continuationData.checkpointed_task_id,
3180
- context: requestBody.context,
3181
- context_id: requestBody.context_id,
3182
- mcp_context_id: requestBody.mcp_context_id,
3183
- stream: true,
3184
- };
3185
- const continueController = new AbortController();
3186
- const continueTimeoutId = timeoutMs > 0 ? setTimeout(() => continueController.abort(), timeoutMs) : null;
3187
- try {
3188
- const continueHeaders = await this.getV3AgentHeaders();
3189
- const continueResponse = await fetch(this.getV3AgentContinueUrl(baseUrl), {
3190
- method: 'POST',
3191
- headers: { ...continueHeaders, 'Content-Type': 'application/json' },
3192
- body: JSON.stringify(continueBody),
3193
- signal: continueController.signal,
3194
- });
3195
- if (!continueResponse.ok) {
4520
+ catch {
3196
4521
  break; // Fall through to normal completion with partial data
3197
4522
  }
3198
- continuationData = await this.collectV3AgentStream(continueResponse, requestExecutionContext);
3199
- }
3200
- catch {
3201
- break; // Fall through to normal completion with partial data
3202
- }
3203
- finally {
3204
- if (continueTimeoutId)
3205
- clearTimeout(continueTimeoutId);
4523
+ finally {
4524
+ if (continueTimeoutId)
4525
+ clearTimeout(continueTimeoutId);
4526
+ }
3206
4527
  }
4528
+ // Use the final continuation data for workspace recovery
4529
+ this.recoverAgentWorkspaceFiles(executionContext, continuationData.files || {}, expectedFiles);
4530
+ await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
4531
+ await this.ensureAgentFrontendPolish(message, executionContext);
4532
+ const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4533
+ const finalContextId = continuationData.context_id || data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
4534
+ return {
4535
+ content: this.formatV3AgentResponse({
4536
+ ...continuationData,
4537
+ liveToolEvidence: requestExecutionContext.liveToolEvidence,
4538
+ }) || this.formatV3AgentResponse({
4539
+ ...data,
4540
+ liveToolEvidence: requestExecutionContext.liveToolEvidence,
4541
+ }),
4542
+ taskId: continuationData.task_id || data.task_id || null,
4543
+ contextId: finalContextId,
4544
+ backendUrl: baseUrl,
4545
+ partial: continuationData.checkpointed === true,
4546
+ metadata: { source: 'v3-agent', mode: 'agent', contextId: finalContextId, continuations, previewGate },
4547
+ };
3207
4548
  }
3208
- // Use the final continuation data for workspace recovery
3209
- this.recoverAgentWorkspaceFiles(executionContext, continuationData.files || {}, expectedFiles);
4549
+ const contextId = data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
4550
+ const mcpContextId = response.headers.get('x-mcp-context-id') || requestExecutionContext.mcpContextId || null;
4551
+ this.recoverAgentWorkspaceFiles(executionContext, data.files || {}, expectedFiles);
3210
4552
  await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
3211
4553
  await this.ensureAgentFrontendPolish(message, executionContext);
3212
4554
  const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
3213
- const finalContextId = continuationData.context_id || data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
4555
+ if (previewGate.required && previewGate.passed !== true && !this.hasAgentWorkspaceOutput(executionContext)) {
4556
+ errors.push(`${baseUrl}: workflow rejected the result - ${previewGate.error || 'Workspace changes were not fully validated.'}`);
4557
+ continue;
4558
+ }
3214
4559
  return {
3215
- content: this.formatV3AgentResponse(continuationData) || this.formatV3AgentResponse(data),
3216
- taskId: continuationData.task_id || data.task_id || null,
3217
- contextId: finalContextId,
4560
+ content: this.formatV3AgentResponse({
4561
+ ...data,
4562
+ liveToolEvidence: requestExecutionContext.liveToolEvidence,
4563
+ }),
4564
+ taskId: data.task_id || null,
4565
+ contextId,
3218
4566
  backendUrl: baseUrl,
3219
- partial: continuationData.checkpointed === true,
3220
- metadata: { source: 'v3-agent', mode: 'agent', contextId: finalContextId, continuations, previewGate },
4567
+ metadata: { source: 'v3-agent', mode: 'agent', contextId, mcpContextId, previewGate },
3221
4568
  };
3222
4569
  }
3223
- const contextId = data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
3224
- const mcpContextId = response.headers.get('x-mcp-context-id') || requestExecutionContext.mcpContextId || null;
3225
- this.recoverAgentWorkspaceFiles(executionContext, data.files || {}, expectedFiles);
3226
- await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
3227
- await this.ensureAgentFrontendPolish(message, executionContext);
3228
- const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
3229
- if (previewGate.required && previewGate.passed !== true && !this.hasAgentWorkspaceOutput(executionContext)) {
3230
- errors.push(`${baseUrl}: workflow rejected the result - ${previewGate.error || 'Workspace changes were not fully validated.'}`);
3231
- continue;
4570
+ catch (error) {
4571
+ if (error && error.name === 'AbortError' && error.partialData && this.hasAgentWorkspaceOutput(executionContext)) {
4572
+ this.recoverAgentWorkspaceFiles(executionContext, error.partialData.files || {}, expectedFiles);
4573
+ await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
4574
+ await this.ensureAgentFrontendPolish(message, executionContext);
4575
+ const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4576
+ return {
4577
+ content: this.formatV3AgentResponse({
4578
+ ...error.partialData,
4579
+ liveToolEvidence: requestExecutionContext.liveToolEvidence,
4580
+ }) || 'V3 agent wrote workspace files before the request timed out waiting for a final summary.',
4581
+ taskId: error.partialData.task_id || null,
4582
+ contextId: error.partialData.context_id || requestExecutionContext.contextId || executionContext.contextId || null,
4583
+ backendUrl: baseUrl,
4584
+ partial: true,
4585
+ 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 },
4586
+ };
4587
+ }
4588
+ errors.push(`${baseUrl}: ${error?.message || String(error)}`);
3232
4589
  }
3233
- return {
3234
- content: this.formatV3AgentResponse(data),
3235
- taskId: data.task_id || null,
3236
- contextId,
3237
- backendUrl: baseUrl,
3238
- metadata: { source: 'v3-agent', mode: 'agent', contextId, mcpContextId, previewGate },
3239
- };
3240
- }
3241
- catch (error) {
3242
- if (error && error.name === 'AbortError' && error.partialData && this.hasAgentWorkspaceOutput(executionContext)) {
3243
- this.recoverAgentWorkspaceFiles(executionContext, error.partialData.files || {}, expectedFiles);
3244
- await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
3245
- await this.ensureAgentFrontendPolish(message, executionContext);
3246
- const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
3247
- return {
3248
- content: this.formatV3AgentResponse(error.partialData) || 'V3 agent wrote workspace files before the request timed out waiting for a final summary.',
3249
- taskId: error.partialData.task_id || null,
3250
- contextId: error.partialData.context_id || requestExecutionContext.contextId || executionContext.contextId || null,
3251
- backendUrl: baseUrl,
3252
- partial: true,
3253
- 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 },
3254
- };
4590
+ finally {
4591
+ if (timeoutId)
4592
+ clearTimeout(timeoutId);
3255
4593
  }
3256
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
3257
- }
3258
- finally {
3259
- if (timeoutId)
3260
- clearTimeout(timeoutId);
3261
4594
  }
3262
4595
  }
3263
4596
  lastErrors = errors;
@@ -3271,7 +4604,7 @@ document.addEventListener('DOMContentLoaded', () => {
3271
4604
  await new Promise((resolve) => setTimeout(resolve, 1500));
3272
4605
  }
3273
4606
  const errors = lastErrors;
3274
- const onlyUnauthorizedErrors = errors.length > 0 && errors.every((entry) => /V3 agent 401:/i.test(entry));
4607
+ const onlyUnauthorizedErrors = errors.length > 0 && errors.every((entry) => /V3 agent 401:|V3 agent 401 \[|V3 auth preflight 401|V3 auth preflight 403/i.test(entry));
3275
4608
  const usingStoredConfigToken = !process.env.VIGTHORIA_TOKEN
3276
4609
  && !process.env.VIGTHORIA_AUTH_TOKEN
3277
4610
  && Boolean(this.config.get('authToken'));
@@ -3281,7 +4614,16 @@ document.addEventListener('DOMContentLoaded', () => {
3281
4614
  this.config.clearAuth();
3282
4615
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
3283
4616
  }
3284
- throw new Error('V3 agent authentication failed at the V3 service layer while your gateway login token is still valid. Please retry shortly.');
4617
+ const rejectedEndpoints = Array.from(new Set(errors
4618
+ .map((entry) => {
4619
+ const match = entry.match(/\[(https?:\/\/[^\]]+)\]/i);
4620
+ return match ? match[1] : null;
4621
+ })
4622
+ .filter((entry) => Boolean(entry))));
4623
+ const endpointSummary = rejectedEndpoints.length > 0
4624
+ ? ` Rejected V3 endpoints: ${rejectedEndpoints.join(', ')}.`
4625
+ : '';
4626
+ throw new Error(`V3 agent authentication failed at the V3 service layer while your gateway login token is still valid.${endpointSummary} Please retry shortly.`);
3285
4627
  }
3286
4628
  if (preferLocalV3
3287
4629
  && !this.hasAgentWorkspaceOutput(executionContext)
@@ -3506,7 +4848,7 @@ document.addEventListener('DOMContentLoaded', () => {
3506
4848
  *
3507
4849
  * NO localhost fallbacks - CLI is for external users, not server-side!
3508
4850
  */
3509
- async chat(messages, model, useLocal = false) {
4851
+ async chat(messages, model, useLocal = false, options = {}) {
3510
4852
  this.lastChatTransportErrors = [];
3511
4853
  const resolvedModel = this.resolveModelId(model);
3512
4854
  const candidateModels = this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()
@@ -3517,7 +4859,7 @@ document.addEventListener('DOMContentLoaded', () => {
3517
4859
  candidateModels.push(fallbackModel);
3518
4860
  }
3519
4861
  for (const candidateModel of candidateModels) {
3520
- const response = await this.tryChatWithModel(messages, candidateModel, model);
4862
+ const response = await this.tryChatWithModel(messages, candidateModel, model, options);
3521
4863
  if (response) {
3522
4864
  if (candidateModel !== resolvedModel) {
3523
4865
  this.logger.debug(`Recovered chat request using fallback model: ${candidateModel}`);
@@ -3525,79 +4867,100 @@ document.addEventListener('DOMContentLoaded', () => {
3525
4867
  return response;
3526
4868
  }
3527
4869
  }
3528
- // No more localhost fallbacks - CLI is for external users!
3529
- const detail = this.lastChatTransportErrors.length > 0
3530
- ? ` Tried routes: ${this.lastChatTransportErrors.slice(0, 4).join(' | ')}`
3531
- : '';
3532
- throw new CLIError(`AI service unavailable. Please check your internet connection or try again later.${detail}`, 'model_backend');
4870
+ // Keep route diagnostics internal. Users only need to know the Vigthoria service is temporarily unavailable.
4871
+ if (this.lastChatTransportErrors.length > 0) {
4872
+ this.logger.debug(`Chat transport failures: ${this.lastChatTransportErrors.slice(0, 4).join(' | ')}`);
4873
+ }
4874
+ throw new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend');
4875
+ }
4876
+ getLastChatTransportErrors() {
4877
+ return [...this.lastChatTransportErrors];
3533
4878
  }
3534
4879
  shouldSkipCloudRoutes(resolvedModel) {
3535
4880
  return this.shouldSimulateCloudFailure() && this.isCloudModelId(resolvedModel);
3536
4881
  }
3537
- async tryChatWithModel(messages, resolvedModel, requestedModel) {
4882
+ async tryChatWithModel(messages, resolvedModel, requestedModel, options = {}) {
3538
4883
  const routeFailures = [];
3539
- const preferSelfHostedFirst = this.isSelfHostedPreferredModel(resolvedModel, requestedModel);
3540
- if (preferSelfHostedFirst) {
3541
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
3542
- if (selfHostedResponse) {
3543
- return selfHostedResponse;
4884
+ const connectTimeoutMs = this.resolveChatConnectTimeoutMs(options);
4885
+ const idleTimeoutMs = this.resolveChatIdleTimeoutMs(options);
4886
+ const useStream = options.stream === true;
4887
+ const notifyRoute = (label) => {
4888
+ try {
4889
+ options.onRouteAttempt?.(label);
4890
+ }
4891
+ catch {
4892
+ // Spinner callbacks must not break chat routing.
4893
+ }
4894
+ };
4895
+ const tryModelsRoute = async () => {
4896
+ if (this.shouldSkipCloudRoutes(resolvedModel) || this.isCloudModelId(resolvedModel)) {
4897
+ return null;
4898
+ }
4899
+ const token = this.getAccessToken();
4900
+ const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
4901
+ if (useStream) {
4902
+ try {
4903
+ notifyRoute('Streaming from Vigthoria Models API...');
4904
+ return await this.tryStreamingModelRouterChat(this.modelRouterClient.defaults.baseURL || 'https://api.vigthoria.io', messages, resolvedModel, requestedModel, authHeaders, options);
4905
+ }
4906
+ catch (error) {
4907
+ const errMsg = error?.message || 'Unknown error';
4908
+ routeFailures.push(`models-stream:${String(errMsg).slice(0, 120)}`);
4909
+ }
3544
4910
  }
3545
- }
3546
- // STRATEGY 1: Direct Vigthoria Models API (api.vigthoria.io)
3547
- if (!this.shouldSkipCloudRoutes(resolvedModel)) {
3548
4911
  try {
3549
- this.logger.debug(`Direct Vigthoria Models API: ${resolvedModel}`);
3550
- const token = this.getAccessToken();
4912
+ notifyRoute('Connecting to Vigthoria Models API...');
3551
4913
  const response = await this.modelRouterClient.post('/v1/chat/completions', {
3552
4914
  model: resolvedModel,
3553
4915
  messages,
3554
4916
  max_tokens: this.config.get('preferences').maxTokens,
3555
4917
  temperature: 0.7,
3556
4918
  stream: false,
4919
+ cloudAccessApproved: this.isCloudModelId(resolvedModel),
4920
+ routeClass: 'direct-chat',
3557
4921
  }, {
3558
- headers: token ? { Authorization: `Bearer ${token}` } : {},
4922
+ timeout: connectTimeoutMs,
4923
+ headers: {
4924
+ ...authHeaders,
4925
+ ...this.buildDirectChatHeaders(this.isCloudModelId(resolvedModel)),
4926
+ },
3559
4927
  });
3560
- if (response.data.choices && response.data.choices.length > 0) {
3561
- const content = response.data.choices[0].message?.content || response.data.choices[0].text;
3562
- if (typeof content !== 'string' || !content.trim()) {
3563
- this.logger.debug(`Direct API returned empty message content for ${resolvedModel}`);
3564
- }
3565
- else {
3566
- return {
3567
- id: response.data.id || `vigthoria-${Date.now()}`,
3568
- message: content,
3569
- model: response.data.model || resolvedModel || requestedModel,
3570
- usage: response.data.usage,
3571
- };
3572
- }
4928
+ const content = response.data.choices?.[0]?.message?.content || response.data.choices?.[0]?.text;
4929
+ if (typeof content === 'string' && content.trim()) {
4930
+ return {
4931
+ id: response.data.id || `vigthoria-${Date.now()}`,
4932
+ message: content,
4933
+ model: response.data.model || resolvedModel || requestedModel,
4934
+ usage: response.data.usage,
4935
+ };
3573
4936
  }
3574
- this.logger.debug(`Direct API returned no choices for ${resolvedModel}`);
3575
4937
  }
3576
4938
  catch (error) {
3577
4939
  const errMsg = error.response?.data?.error || error.message || 'Unknown error';
3578
- this.logger.debug(`Direct Vigthoria Models API failed for ${resolvedModel}: ${errMsg}`);
3579
4940
  routeFailures.push(`models:${String(errMsg).slice(0, 120)}`);
3580
4941
  }
3581
- }
3582
- else {
3583
- this.logger.debug(`Simulating cloud failure for ${resolvedModel}; skipping cloud-backed public API route.`);
3584
- }
3585
- // STRATEGY 2: Vigthoria Cloud API via Coder (authenticated users only)
3586
- if (this.config.isAuthenticated() && !this.shouldSkipCloudRoutes(resolvedModel)) {
4942
+ return null;
4943
+ };
4944
+ const tryCoderRoute = async () => {
4945
+ if (!this.config.isAuthenticated() || this.shouldSkipCloudRoutes(resolvedModel)) {
4946
+ return null;
4947
+ }
3587
4948
  try {
3588
- this.logger.debug(`Vigthoria Cloud API fallback: ${resolvedModel}`);
4949
+ notifyRoute('Connecting to Vigthoria Coder API...');
3589
4950
  const response = await this.client.post('/api/ai/chat', {
3590
4951
  messages,
3591
4952
  model: resolvedModel,
3592
4953
  maxTokens: this.config.get('preferences').maxTokens,
3593
4954
  temperature: 0.7,
4955
+ routeClass: 'direct-chat',
4956
+ cloudAccessApproved: this.isCloudModelId(resolvedModel),
4957
+ }, {
4958
+ timeout: idleTimeoutMs,
4959
+ headers: this.buildDirectChatHeaders(this.isCloudModelId(resolvedModel)),
3594
4960
  });
3595
4961
  if (response.data.success !== false) {
3596
4962
  const content = response.data.response || response.data.message || response.data.content;
3597
- if (typeof content !== 'string' || !content.trim()) {
3598
- this.logger.debug(`Cloud API returned empty message content for ${resolvedModel}`);
3599
- }
3600
- else {
4963
+ if (typeof content === 'string' && content.trim()) {
3601
4964
  return {
3602
4965
  id: response.data.id || `vigthoria-coder-${Date.now()}`,
3603
4966
  message: content,
@@ -3606,48 +4969,81 @@ document.addEventListener('DOMContentLoaded', () => {
3606
4969
  };
3607
4970
  }
3608
4971
  }
3609
- this.logger.debug(`Cloud API returned success=false for ${resolvedModel}`);
3610
4972
  }
3611
4973
  catch (error) {
3612
4974
  const errMsg = error.response?.data?.error || error.message || 'Unknown error';
3613
- this.logger.debug(`Vigthoria Cloud API failed for ${resolvedModel}: ${errMsg}`);
3614
4975
  routeFailures.push(`coder:${String(errMsg).slice(0, 120)}`);
3615
4976
  }
4977
+ return null;
4978
+ };
4979
+ const tryCanonicalRoute = async () => {
4980
+ if (options.fastFail || this.isCanonicalCoderDuplicate() || !this.config.isAuthenticated()) {
4981
+ return null;
4982
+ }
3616
4983
  try {
3617
- this.logger.debug(`Canonical Vigthoria Cloud fallback: ${resolvedModel}`);
4984
+ notifyRoute('Connecting to Vigthoria Coder (canonical)...');
3618
4985
  const token = this.getAccessToken();
3619
4986
  const response = await axios.post('https://coder.vigthoria.io/api/ai/chat', {
3620
4987
  messages,
3621
4988
  model: resolvedModel,
3622
4989
  maxTokens: this.config.get('preferences').maxTokens,
3623
4990
  temperature: 0.7,
4991
+ routeClass: 'direct-chat',
4992
+ cloudAccessApproved: this.isCloudModelId(resolvedModel),
3624
4993
  }, {
3625
- timeout: 180000,
4994
+ timeout: idleTimeoutMs,
3626
4995
  httpsAgent: this._httpsAgent ?? undefined,
3627
- headers: token ? { Authorization: `Bearer ${token}`, Cookie: `vigthoria-auth-token=${token}` } : {},
4996
+ headers: {
4997
+ ...(token ? { Authorization: `Bearer ${token}`, Cookie: `vigthoria-auth-token=${token}` } : {}),
4998
+ ...this.buildDirectChatHeaders(this.isCloudModelId(resolvedModel)),
4999
+ },
3628
5000
  });
3629
- if (response.data?.success !== false) {
3630
- const content = response.data.response || response.data.message || response.data.content;
3631
- if (typeof content === 'string' && content.trim()) {
3632
- return {
3633
- id: response.data.id || `vigthoria-coder-canonical-${Date.now()}`,
3634
- message: content,
3635
- model: response.data.model || resolvedModel || requestedModel,
3636
- usage: response.data.usage,
3637
- };
3638
- }
5001
+ const content = response.data?.response || response.data?.message || response.data?.content;
5002
+ if (response.data?.success !== false && typeof content === 'string' && content.trim()) {
5003
+ return {
5004
+ id: response.data.id || `vigthoria-coder-canonical-${Date.now()}`,
5005
+ message: content,
5006
+ model: response.data.model || resolvedModel || requestedModel,
5007
+ usage: response.data.usage,
5008
+ };
3639
5009
  }
3640
5010
  }
3641
5011
  catch (error) {
3642
5012
  const errMsg = error.response?.data?.error || error.message || 'Unknown error';
3643
- this.logger.debug(`Canonical Vigthoria Cloud fallback failed for ${resolvedModel}: ${errMsg}`);
3644
5013
  routeFailures.push(`coder-canonical:${String(errMsg).slice(0, 120)}`);
3645
5014
  }
3646
- }
3647
- if (!preferSelfHostedFirst) {
3648
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
3649
- if (selfHostedResponse) {
3650
- return selfHostedResponse;
5015
+ return null;
5016
+ };
5017
+ const trySelfHostedRoute = async () => {
5018
+ notifyRoute('Connecting to dedicated model endpoint...');
5019
+ return this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel, idleTimeoutMs, options);
5020
+ };
5021
+ const preferred = options.preferredRoute
5022
+ || (this.config.isAuthenticated() ? 'coder' : 'models');
5023
+ const singleRouteMode = options.fastFail === true && (options.singleRoute === true || !!options.preferredRoute);
5024
+ const routeOrder = singleRouteMode && options.preferredRoute
5025
+ ? [options.preferredRoute]
5026
+ : preferred === 'coder'
5027
+ ? ['coder', 'models', 'selfhosted']
5028
+ : preferred === 'selfhosted'
5029
+ ? ['selfhosted', 'coder', 'models']
5030
+ : ['models', 'coder', 'selfhosted'];
5031
+ for (const route of routeOrder) {
5032
+ let result = null;
5033
+ if (route === 'coder') {
5034
+ result = await tryCoderRoute();
5035
+ if (!result) {
5036
+ result = await tryCanonicalRoute();
5037
+ }
5038
+ }
5039
+ else if (route === 'models') {
5040
+ result = await tryModelsRoute();
5041
+ }
5042
+ else {
5043
+ result = await trySelfHostedRoute();
5044
+ }
5045
+ if (result) {
5046
+ return result;
3651
5047
  }
3652
5048
  }
3653
5049
  if (routeFailures.length > 0) {
@@ -3655,13 +5051,13 @@ document.addEventListener('DOMContentLoaded', () => {
3655
5051
  }
3656
5052
  return null;
3657
5053
  }
3658
- async trySelfHostedChatWithModel(messages, resolvedModel, requestedModel) {
5054
+ async trySelfHostedChatWithModel(messages, resolvedModel, requestedModel, requestTimeoutMs, options = {}) {
3659
5055
  if (!this.selfHostedModelRouterClient || !this.shouldTrySelfHostedFallback(resolvedModel, requestedModel)) {
3660
5056
  return null;
3661
5057
  }
3662
5058
  const selfHostedModel = this.getSelfHostedFallbackModelId(resolvedModel, requestedModel);
3663
5059
  try {
3664
- this.logger.debug(`Self-hosted Vigthoria GPU fallback: ${selfHostedModel}`);
5060
+ this.logger.debug(`Dedicated Vigthoria model endpoint fallback: ${selfHostedModel}`);
3665
5061
  const response = await this.selfHostedModelRouterClient.post('/v1/chat/completions', {
3666
5062
  model: selfHostedModel,
3667
5063
  messages,
@@ -3669,6 +5065,7 @@ document.addEventListener('DOMContentLoaded', () => {
3669
5065
  temperature: 0.7,
3670
5066
  stream: false,
3671
5067
  }, {
5068
+ timeout: requestTimeoutMs,
3672
5069
  headers: {
3673
5070
  'x-agent-mode': 'true',
3674
5071
  },
@@ -3676,21 +5073,21 @@ document.addEventListener('DOMContentLoaded', () => {
3676
5073
  if (response.data.choices && response.data.choices.length > 0) {
3677
5074
  const content = response.data.choices[0].message?.content || response.data.choices[0].text;
3678
5075
  if (typeof content !== 'string' || !content.trim()) {
3679
- this.logger.debug(`Self-hosted route returned empty message content for ${selfHostedModel}`);
5076
+ this.logger.debug(`Dedicated Vigthoria model endpoint returned empty message content for ${selfHostedModel}`);
3680
5077
  return null;
3681
5078
  }
3682
5079
  return {
3683
- id: response.data.id || `vigthoria-self-hosted-${Date.now()}`,
5080
+ id: response.data.id || `vigthoria-dedicated-${Date.now()}`,
3684
5081
  message: content,
3685
5082
  model: response.data.model || selfHostedModel,
3686
5083
  usage: response.data.usage,
3687
5084
  };
3688
5085
  }
3689
- this.logger.debug(`Self-hosted route returned no choices for ${selfHostedModel}`);
5086
+ this.logger.debug(`Dedicated Vigthoria model endpoint returned no choices for ${selfHostedModel}`);
3690
5087
  }
3691
5088
  catch (error) {
3692
5089
  const errMsg = error.response?.data?.error || error.message || 'Unknown error';
3693
- this.logger.debug(`Self-hosted Vigthoria GPU fallback failed for ${selfHostedModel}: ${errMsg}`);
5090
+ this.logger.debug(`Dedicated Vigthoria model endpoint fallback failed for ${selfHostedModel}: ${errMsg}`);
3694
5091
  }
3695
5092
  return null;
3696
5093
  }
@@ -3708,11 +5105,37 @@ document.addEventListener('DOMContentLoaded', () => {
3708
5105
  return null;
3709
5106
  }
3710
5107
  isCloudModelId(resolvedModel) {
3711
- return resolvedModel === 'deepseek-v3.1:671b-cloud'
3712
- || resolvedModel === 'moonshotai/kimi-k2.5'
3713
- || resolvedModel === 'vigthoria-cloud-pro'
3714
- || resolvedModel === 'vigthoria-cloud-k2'
3715
- || resolvedModel === 'vigthoria-cloud-ultra';
5108
+ const normalized = String(resolvedModel || '').trim().toLowerCase();
5109
+ const cloudIds = new Set([
5110
+ 'deepseek-v3.1:671b-cloud',
5111
+ 'moonshotai/kimi-k2.5',
5112
+ 'vigthoria-cloud-pro',
5113
+ 'vigthoria-cloud-k2',
5114
+ 'vigthoria-cloud-ultra',
5115
+ 'vigthoria-cloud-fast',
5116
+ 'vigthoria-cloud-balanced',
5117
+ 'vigthoria-cloud-code',
5118
+ 'vigthoria-cloud-power',
5119
+ 'vigthoria-cloud-maximum',
5120
+ 'cloud-fast',
5121
+ 'cloud-balanced',
5122
+ 'cloud-code',
5123
+ 'cloud-power',
5124
+ 'cloud-maximum',
5125
+ 'cloud',
5126
+ 'cloud-reason',
5127
+ 'ultra',
5128
+ ]);
5129
+ return cloudIds.has(normalized) || normalized.includes('vigthoria-cloud-');
5130
+ }
5131
+ buildDirectChatHeaders(isCloud) {
5132
+ const headers = {
5133
+ 'x-vigthoria-route-class': 'direct-chat',
5134
+ };
5135
+ if (isCloud) {
5136
+ headers['x-vigthoria-cloud-approved'] = 'true';
5137
+ }
5138
+ return headers;
3716
5139
  }
3717
5140
  canUseCloudModel() {
3718
5141
  return this.config.hasCloudAccess();
@@ -4701,7 +6124,7 @@ document.addEventListener('DOMContentLoaded', () => {
4701
6124
  resolveModelId(shortName) {
4702
6125
  const modelMap = {
4703
6126
  // ═══════════════════════════════════════════════════════════════
4704
- // VIGTHORIA LOCAL - Self-hosted models
6127
+ // Vigthoria server infrastructure models
4705
6128
  // ═══════════════════════════════════════════════════════════════
4706
6129
  'fast': 'vigthoria-v3-balanced-4b',
4707
6130
  'mini': 'vigthoria-mini-0.6b',
@@ -4709,7 +6132,7 @@ document.addEventListener('DOMContentLoaded', () => {
4709
6132
  'balanced-4b': 'vigthoria-v3-balanced-4b',
4710
6133
  'creative': 'vigthoria-creative-9b-v4',
4711
6134
  // Code Models - 35B is the default powerhouse
4712
- 'code': 'vigthoria-v3-code-35b', // Internal: self-hosted 35B on Blackwell
6135
+ 'code': 'vigthoria-v3-code-35b', // Vigthoria server infrastructure 35B model
4713
6136
  'code-30b': 'vigthoria-v3-code-35b',
4714
6137
  'code-35b': 'vigthoria-v3-code-35b',
4715
6138
  'code-8b': 'vigthoria-v3-code-9b',
@@ -4754,24 +6177,59 @@ document.addEventListener('DOMContentLoaded', () => {
4754
6177
  };
4755
6178
  }
4756
6179
  }
6180
+ isHealthyServicePayload(payload) {
6181
+ const status = String(payload?.status || '').toLowerCase();
6182
+ return payload?.healthy === true
6183
+ || status === 'healthy'
6184
+ || status === 'ok'
6185
+ || status === 'ready'
6186
+ || status === 'degraded';
6187
+ }
6188
+ extractModelCount(payload) {
6189
+ if (Array.isArray(payload?.data))
6190
+ return payload.data.length;
6191
+ if (Array.isArray(payload?.models))
6192
+ return payload.models.length;
6193
+ if (Array.isArray(payload?.model_list))
6194
+ return payload.model_list.length;
6195
+ if (typeof payload?.total === 'number')
6196
+ return payload.total;
6197
+ return 0;
6198
+ }
6199
+ async probeModelList(client) {
6200
+ const candidates = ['/v1/models', '/models', '/api/models', '/api/tags'];
6201
+ for (const endpoint of candidates) {
6202
+ try {
6203
+ const response = await client.get(endpoint, { timeout: 5000 });
6204
+ const modelCount = this.extractModelCount(response.data);
6205
+ if (modelCount > 0) {
6206
+ return { modelCount, endpoint };
6207
+ }
6208
+ }
6209
+ catch {
6210
+ // try next endpoint
6211
+ }
6212
+ }
6213
+ return { modelCount: 0, endpoint: candidates[0] };
6214
+ }
4757
6215
  async getModelsHealth() {
4758
6216
  const modelsApiUrl = this.config.get('modelsApiUrl');
4759
6217
  try {
4760
- const [healthResponse, modelsResponse] = await Promise.all([
6218
+ const [healthResponse, modelProbe] = await Promise.all([
4761
6219
  this.modelRouterClient.get('/health', { timeout: 5000 }),
4762
- this.modelRouterClient.get('/v1/models', { timeout: 5000 }),
6220
+ this.probeModelList(this.modelRouterClient),
4763
6221
  ]);
4764
- const healthOk = healthResponse.data?.status === 'healthy'
4765
- || healthResponse.data?.status === 'ok'
4766
- || healthResponse.data?.healthy === true;
4767
- const modelCount = Array.isArray(modelsResponse.data?.data) ? modelsResponse.data.data.length : 0;
6222
+ const healthOk = this.isHealthyServicePayload(healthResponse.data);
6223
+ const modelCount = modelProbe.modelCount;
6224
+ const ok = healthOk || modelCount > 0;
4768
6225
  return {
4769
6226
  name: 'Models API',
4770
6227
  endpoint: `${modelsApiUrl}/health`,
4771
- ok: healthOk && modelCount > 0,
6228
+ ok,
4772
6229
  details: {
4773
6230
  health: healthResponse.data,
4774
6231
  modelCount,
6232
+ modelsEndpoint: modelProbe.endpoint,
4775
6233
  },
4776
6234
  };
4777
6235
  }
@@ -4790,20 +6248,26 @@ document.addEventListener('DOMContentLoaded', () => {
4790
6248
  return null;
4791
6249
  }
4792
6250
  try {
4793
- const response = await this.selfHostedModelRouterClient.get('/health', { timeout: 5000 });
4794
- const ok = response.data?.status === 'healthy'
4795
- || response.data?.status === 'ok'
4796
- || response.data?.healthy === true;
6251
+ const [healthResponse, modelProbe] = await Promise.all([
6252
+ this.selfHostedModelRouterClient.get('/health', { timeout: 5000 }),
6253
+ this.probeModelList(this.selfHostedModelRouterClient),
6254
+ ]);
6255
+ const healthOk = this.isHealthyServicePayload(healthResponse.data);
6256
+ const ok = healthOk || modelProbe.modelCount > 0;
4797
6257
  return {
4798
- name: 'Self-hosted Models API',
6258
+ name: 'Vigthoria Models API',
4799
6259
  endpoint: `${selfHostedModelsApiUrl}/health`,
4800
6260
  ok,
4801
- details: { health: response.data },
6261
+ details: {
6262
+ health: healthResponse.data,
6263
+ modelCount: modelProbe.modelCount,
6264
+ modelsEndpoint: modelProbe.endpoint,
6265
+ },
4802
6266
  };
4803
6267
  }
4804
6268
  catch (error) {
4805
6269
  return {
4806
- name: 'Self-hosted Models API',
6270
+ name: 'Vigthoria Models API',
4807
6271
  endpoint: `${selfHostedModelsApiUrl}/health`,
4808
6272
  ok: false,
4809
6273
  error: error.message,
@@ -5056,7 +6520,7 @@ document.addEventListener('DOMContentLoaded', () => {
5056
6520
  withTimeout(this.getDevtoolsBridgeStatus(), 'DevTools Bridge'),
5057
6521
  ]);
5058
6522
  return {
5059
- overallOk: v3Agent.ok && hyperLoop.ok && repoMemory.ok,
6523
+ overallOk: v3Agent.ok && (hyperLoop.ok || repoMemory.ok),
5060
6524
  v3Agent,
5061
6525
  hyperLoop,
5062
6526
  repoMemory,