vigthoria-cli 1.13.18 → 1.13.22

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.
Files changed (185) hide show
  1. package/completions/_vigthoria +49 -0
  2. package/completions/vigthoria.bash +6 -0
  3. package/completions/vigthoria.fish +41 -0
  4. package/dist/commands/auth.d.ts +1 -0
  5. package/dist/commands/auth.js +84 -135
  6. package/dist/commands/background.d.ts +0 -2
  7. package/dist/commands/background.js +26 -34
  8. package/dist/commands/bridge.js +45 -41
  9. package/dist/commands/cancel.d.ts +1 -0
  10. package/dist/commands/cancel.js +22 -16
  11. package/dist/commands/chat.d.ts +5 -42
  12. package/dist/commands/chat.js +462 -1122
  13. package/dist/commands/coding-registration.d.ts +4 -0
  14. package/dist/commands/coding-registration.js +24 -0
  15. package/dist/commands/config.js +32 -42
  16. package/dist/commands/conversation-registration.d.ts +4 -0
  17. package/dist/commands/conversation-registration.js +122 -0
  18. package/dist/commands/deploy.d.ts +8 -2
  19. package/dist/commands/deploy.js +92 -90
  20. package/dist/commands/device.d.ts +1 -2
  21. package/dist/commands/device.js +37 -21
  22. package/dist/commands/doctor-registration.d.ts +4 -0
  23. package/dist/commands/doctor-registration.js +200 -0
  24. package/dist/commands/edit.js +22 -33
  25. package/dist/commands/explain.js +7 -10
  26. package/dist/commands/fork.d.ts +1 -2
  27. package/dist/commands/fork.js +72 -22
  28. package/dist/commands/game.d.ts +1 -1
  29. package/dist/commands/game.js +17 -6
  30. package/dist/commands/generate.js +7 -9
  31. package/dist/commands/history.js +11 -7
  32. package/dist/commands/hub.d.ts +2 -2
  33. package/dist/commands/hub.js +34 -35
  34. package/dist/commands/legion.d.ts +0 -1
  35. package/dist/commands/legion.js +62 -79
  36. package/dist/commands/music.d.ts +1 -2
  37. package/dist/commands/music.js +62 -27
  38. package/dist/commands/platform-registration.d.ts +4 -0
  39. package/dist/commands/platform-registration.js +45 -0
  40. package/dist/commands/preview.d.ts +0 -1
  41. package/dist/commands/preview.js +82 -35
  42. package/dist/commands/product-run-registration.d.ts +4 -0
  43. package/dist/commands/product-run-registration.js +490 -0
  44. package/dist/commands/replay.d.ts +1 -2
  45. package/dist/commands/replay.js +15 -11
  46. package/dist/commands/repo.d.ts +14 -7
  47. package/dist/commands/repo.js +245 -196
  48. package/dist/commands/review.js +9 -12
  49. package/dist/commands/security.d.ts +1 -2
  50. package/dist/commands/security.js +12 -6
  51. package/dist/commands/update-registration.d.ts +3 -0
  52. package/dist/commands/update-registration.js +338 -0
  53. package/dist/commands/v4-registration.d.ts +4 -0
  54. package/dist/commands/v4-registration.js +24 -0
  55. package/dist/commands/v4.d.ts +1 -1
  56. package/dist/commands/v4.js +46 -8
  57. package/dist/commands/wallet.d.ts +1 -3
  58. package/dist/commands/wallet.js +18 -45
  59. package/dist/commands/workflow.d.ts +2 -0
  60. package/dist/commands/workflow.js +52 -39
  61. package/dist/index.js +122 -1498
  62. package/dist/utils/agent-stream-state.d.ts +27 -0
  63. package/dist/utils/agent-stream-state.js +93 -0
  64. package/dist/utils/agentRoute.d.ts +2 -1
  65. package/dist/utils/agentRoute.js +22 -9
  66. package/dist/utils/agentRunOutcome.d.ts +16 -0
  67. package/dist/utils/agentRunOutcome.js +37 -2
  68. package/dist/utils/api-client-factory.d.ts +8 -0
  69. package/dist/utils/api-client-factory.js +8 -0
  70. package/dist/utils/api.d.ts +45 -269
  71. package/dist/utils/api.js +664 -3210
  72. package/dist/utils/auth-session.d.ts +41 -0
  73. package/dist/utils/auth-session.js +224 -0
  74. package/dist/utils/brain-hub-client.d.ts +0 -4
  75. package/dist/utils/brain-hub-client.js +6 -3
  76. package/dist/utils/bridge-client.js +2 -0
  77. package/dist/utils/capability-health-service.d.ts +48 -0
  78. package/dist/utils/capability-health-service.js +64 -0
  79. package/dist/utils/chat-prompt-policy.d.ts +18 -0
  80. package/dist/utils/chat-prompt-policy.js +61 -0
  81. package/dist/utils/chat-tool-call-parser.d.ts +11 -0
  82. package/dist/utils/chat-tool-call-parser.js +71 -0
  83. package/dist/utils/cli-state.d.ts +22 -4
  84. package/dist/utils/cli-state.js +105 -47
  85. package/dist/utils/code-operations-service.d.ts +104 -0
  86. package/dist/utils/code-operations-service.js +837 -0
  87. package/dist/utils/codebase-indexer.d.ts +1 -0
  88. package/dist/utils/codebase-indexer.js +28 -8
  89. package/dist/utils/command-contract.d.ts +61 -0
  90. package/dist/utils/command-contract.js +149 -0
  91. package/dist/utils/command-policy.d.ts +10 -0
  92. package/dist/utils/command-policy.js +54 -0
  93. package/dist/utils/config.d.ts +13 -5
  94. package/dist/utils/config.js +227 -99
  95. package/dist/utils/context-ranker.js +11 -2
  96. package/dist/utils/context-state.d.ts +39 -0
  97. package/dist/utils/context-state.js +52 -0
  98. package/dist/utils/deckEvents.d.ts +0 -5
  99. package/dist/utils/deckEvents.js +3 -2
  100. package/dist/utils/desktop-bridge-client.js +2 -0
  101. package/dist/utils/direct-output-policy.d.ts +3 -0
  102. package/dist/utils/direct-output-policy.js +76 -0
  103. package/dist/utils/durable-json.d.ts +57 -0
  104. package/dist/utils/durable-json.js +319 -0
  105. package/dist/utils/fastAgentRouter.d.ts +6 -7
  106. package/dist/utils/fastAgentRouter.js +21 -30
  107. package/dist/utils/files.js +21 -13
  108. package/dist/utils/frontend-preview-service.d.ts +76 -0
  109. package/dist/utils/frontend-preview-service.js +482 -0
  110. package/dist/utils/goaEvents.d.ts +0 -4
  111. package/dist/utils/goaEvents.js +5 -2
  112. package/dist/utils/logger.d.ts +2 -1
  113. package/dist/utils/logger.js +27 -20
  114. package/dist/utils/mcp-context-client.d.ts +24 -0
  115. package/dist/utils/mcp-context-client.js +58 -0
  116. package/dist/utils/model-governance.d.ts +18 -0
  117. package/dist/utils/model-governance.js +75 -0
  118. package/dist/utils/model-transport-service.d.ts +44 -0
  119. package/dist/utils/model-transport-service.js +111 -0
  120. package/dist/utils/mutation-journal.d.ts +42 -0
  121. package/dist/utils/mutation-journal.js +387 -0
  122. package/dist/utils/network-policy.d.ts +24 -0
  123. package/dist/utils/network-policy.js +202 -0
  124. package/dist/utils/operator-client.d.ts +45 -0
  125. package/dist/utils/operator-client.js +159 -0
  126. package/dist/utils/post-write-validator.js +7 -9
  127. package/dist/utils/preview-screenshot-adapter.d.ts +13 -0
  128. package/dist/utils/preview-screenshot-adapter.js +36 -0
  129. package/dist/utils/process-policy.d.ts +23 -0
  130. package/dist/utils/process-policy.js +284 -0
  131. package/dist/utils/project-memory.d.ts +13 -3
  132. package/dist/utils/project-memory.js +70 -17
  133. package/dist/utils/prompt-workspace-resolver.d.ts +14 -0
  134. package/dist/utils/prompt-workspace-resolver.js +64 -0
  135. package/dist/utils/release-install.d.ts +11 -0
  136. package/dist/utils/release-install.js +155 -0
  137. package/dist/utils/release-policy.d.ts +64 -0
  138. package/dist/utils/release-policy.js +293 -0
  139. package/dist/utils/requestIntent.js +22 -3
  140. package/dist/utils/runtime-capability.d.ts +8 -0
  141. package/dist/utils/runtime-capability.js +16 -0
  142. package/dist/utils/secret-policy.d.ts +20 -0
  143. package/dist/utils/secret-policy.js +160 -0
  144. package/dist/utils/session.d.ts +34 -2
  145. package/dist/utils/session.js +354 -110
  146. package/dist/utils/tool-approval-service.d.ts +17 -0
  147. package/dist/utils/tool-approval-service.js +78 -0
  148. package/dist/utils/tool-capability-providers.d.ts +57 -0
  149. package/dist/utils/tool-capability-providers.js +57 -0
  150. package/dist/utils/tools.d.ts +2 -23
  151. package/dist/utils/tools.js +216 -508
  152. package/dist/utils/update-policy.d.ts +18 -0
  153. package/dist/utils/update-policy.js +137 -0
  154. package/dist/utils/v3-agent-client.d.ts +40 -0
  155. package/dist/utils/v3-agent-client.js +165 -0
  156. package/dist/utils/v3-workspace-service.d.ts +28 -0
  157. package/dist/utils/v3-workspace-service.js +279 -0
  158. package/dist/utils/vigflow-client.d.ts +95 -0
  159. package/dist/utils/vigflow-client.js +105 -0
  160. package/dist/utils/workspace-boundary.d.ts +45 -0
  161. package/dist/utils/workspace-boundary.js +377 -0
  162. package/dist/utils/workspace-brain-service.d.ts +2 -0
  163. package/dist/utils/workspace-brain-service.js +11 -1
  164. package/dist/utils/workspace-cache.js +37 -15
  165. package/dist/utils/workspace-stream.js +35 -10
  166. package/install.ps1 +86 -143
  167. package/install.sh +76 -81
  168. package/package.json +44 -9
  169. package/release-policy.json +43 -0
  170. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  171. package/scripts/release/generate-completions.mjs +24 -0
  172. package/scripts/release/install-release.mjs +10 -0
  173. package/scripts/release/publish-cli-release.mjs +116 -0
  174. package/scripts/release/publish-cli-release.sh +2 -70
  175. package/scripts/release/resolve-release-manifest.mjs +26 -0
  176. package/scripts/release/test-balanced-model-live.sh +42 -0
  177. package/scripts/release/validate-live-service-gates.sh +90 -0
  178. package/scripts/release/validate-no-go-gates.sh +135 -172
  179. package/scripts/release/verify-runtime-consistency.mjs +4 -1
  180. package/dist/commands/index.d.ts +0 -12
  181. package/dist/commands/index.js +0 -194
  182. package/dist/commands/update.d.ts +0 -9
  183. package/dist/commands/update.js +0 -301
  184. package/dist/utils/templateInstantPath.d.ts +0 -18
  185. package/dist/utils/templateInstantPath.js +0 -228
package/dist/utils/api.js CHANGED
@@ -3,23 +3,38 @@
3
3
  * Connects to coder.vigthoria.io API endpoints
4
4
  */
5
5
  import axios from 'axios';
6
- import { execFile } from 'child_process';
7
6
  import { randomUUID } from 'crypto';
8
7
  import fs from 'fs';
9
8
  import https from 'https';
10
9
  import net from 'net';
11
10
  import path from 'path';
12
- import WebSocket from 'ws';
13
11
  import { globSync } from 'glob';
12
+ import { AuthSessionService, subscribeAuthInvalidation } from './auth-session.js';
13
+ import { appendContextLayer } from './context-state.js';
14
14
  import { buildSemanticContext } from './context-ranker.js';
15
15
  import { getChangedFiles } from './workspace-cache.js';
16
16
  import { isSubstantiveAgentAnswer, isToolEvidenceStubAnswer } from './agentRunOutcome.js';
17
- import { isV3WorkspaceRootAliasPath, joinV3WorkspacePath, normalizeV3WorkspaceRelativePath } from './v3-workspace-path.js';
18
17
  import { buildExecutionHints, isPlannerBuildTask, resolvePlannerAgentTimeoutMs } from './requestIntent.js';
19
18
  import { isV3StreamKeepaliveEvent } from './v3-stream-events.js';
20
19
  import { resolveV3ContextCharLimit, WORKSPACE_FILE_CHAR_CAP, } from './contextBudget.js';
21
20
  import { isLocalTestfarmMode, fetchWithServiceTimeout } from './localTestMode.js';
22
21
  import { buildClientManifest } from './clientManifest.js';
22
+ import { assertTrustedEndpoint, installAxiosNetworkPolicy, installGlobalFetchPolicy } from './network-policy.js';
23
+ import { assertSafeRelativePath, resolveWorkspacePath } from './workspace-boundary.js';
24
+ import { parseSupportedProcess } from './process-policy.js';
25
+ import { OptionalPuppeteerScreenshotAdapter } from './preview-screenshot-adapter.js';
26
+ import { ModelGovernance } from './model-governance.js';
27
+ import { OperatorClient, OperatorClientError } from './operator-client.js';
28
+ import { CodeOperationsService } from './code-operations-service.js';
29
+ import { McpContextClient } from './mcp-context-client.js';
30
+ import { FrontendPreviewService } from './frontend-preview-service.js';
31
+ import { V3AgentClient } from './v3-agent-client.js';
32
+ import { V3WorkspaceService } from './v3-workspace-service.js';
33
+ import { CapabilityHealthService, } from './capability-health-service.js';
34
+ import { ModelTransportService } from './model-transport-service.js';
35
+ import { VigFlowClient, } from './vigflow-client.js';
36
+ import { MutationJournal, finalizeMutationTransaction, } from './mutation-journal.js';
37
+ import { isSensitivePath, redactSensitiveText, safeChildProcessEnv, scanOutboundContext, } from './secret-policy.js';
23
38
  export const VIGTHORIA_HUB_CREDITS_URL = 'https://hub.vigthoria.io/credits';
24
39
  export const VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE = 'Vigthoria Server is temporarily not available. Please try again later. If the issue persists, please contact support.';
25
40
  export class CLIError extends Error {
@@ -183,28 +198,13 @@ export function sanitizeUserFacingErrorText(input) {
183
198
  return '';
184
199
  }
185
200
  const withoutTags = raw.replace(/<[^>]+>/g, ' ');
186
- return sanitizeUserFacingPathText(withoutTags)
201
+ return redactSensitiveText(sanitizeUserFacingPathText(withoutTags))
187
202
  .replace(/\\[nr]/g, ' ')
188
203
  .replace(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(?:\/[^\s'")\]]*)?/gi, 'internal AI service')
189
204
  .replace(/\b(?:deepseek|openrouter)\/[A-Za-z0-9._/-]+\b/gi, 'cloud model')
190
205
  .replace(/\s+/g, ' ')
191
206
  .trim();
192
207
  }
193
- const TRUSTED_TOKEN_HOST_PATTERN = /(^|\.)vigthoria\.io$/i;
194
- function isLoopbackHost(hostname) {
195
- const host = String(hostname || '').toLowerCase();
196
- return host === 'localhost' || host === '127.0.0.1';
197
- }
198
- function isTrustedTokenDestination(rawUrl) {
199
- try {
200
- const parsed = new URL(rawUrl);
201
- const host = parsed.hostname.toLowerCase();
202
- return TRUSTED_TOKEN_HOST_PATTERN.test(host) || isLoopbackHost(host);
203
- }
204
- catch {
205
- return false;
206
- }
207
- }
208
208
  function resolveAxiosRequestUrl(req) {
209
209
  const direct = String(req?.url || '').trim();
210
210
  const base = String(req?.baseURL || '').trim();
@@ -222,14 +222,6 @@ function resolveAxiosRequestUrl(req) {
222
222
  }
223
223
  return direct;
224
224
  }
225
- export function isServerRuntime() {
226
- if (process.env.VIGTHORIA_ALLOW_LOCAL_SERVICES === '1') {
227
- return true;
228
- }
229
- const host = String(process.env.HOSTNAME || '').toLowerCase();
230
- const cwd = String(process.cwd() || '').toLowerCase();
231
- return host.includes('ubuntu') || cwd.startsWith('/var/www');
232
- }
233
225
  export function describeUpstreamStatus(status) {
234
226
  if (status >= 500)
235
227
  return 'upstream internal error';
@@ -307,19 +299,141 @@ export class APIClient {
307
299
  modelRouterClient;
308
300
  selfHostedModelRouterClient;
309
301
  config;
302
+ authSession;
303
+ unsubscribeAuthInvalidation = null;
310
304
  logger;
311
- ws = null;
312
- vigFlowTokens = new Map();
305
+ modelGovernance;
306
+ codeOperations;
307
+ mcpContextClient;
308
+ v3AgentClient;
309
+ v3WorkspaceService;
310
+ capabilityHealthService;
311
+ modelTransportService;
312
+ operatorClient;
313
+ vigFlowClient;
313
314
  _httpsAgent = null;
314
315
  lastChatTransportErrors = [];
315
316
  clientToolErrors = [];
316
317
  pendingV3ClientToolTasks = new Set();
317
318
  /** Tracks files mutated via client_tool_request during an active V3 SSE stream. */
318
319
  activeV3StreamedFiles = null;
319
- activeV3StreamContext = null;
320
- constructor(config, logger) {
320
+ lifecycleAbortController = new AbortController();
321
+ frontendPreviewService;
322
+ withLifecycleSignal(signal) {
323
+ return signal
324
+ ? AbortSignal.any([signal, this.lifecycleAbortController.signal])
325
+ : this.lifecycleAbortController.signal;
326
+ }
327
+ fetch(input, init = {}) {
328
+ return fetch(input, { ...init, signal: this.withLifecycleSignal(init.signal) });
329
+ }
330
+ constructor(config, logger, dependencies = {}) {
321
331
  this.config = config;
322
332
  this.logger = logger;
333
+ this.authSession = new AuthSessionService(config);
334
+ this.modelGovernance = new ModelGovernance({
335
+ hasCloudAccess: () => this.config.hasCloudAccess(),
336
+ hasSelfHostedEndpoint: () => Boolean(this.selfHostedModelRouterClient),
337
+ simulateCloudFailure: () => process.env.VIGTHORIA_SIMULATE_CLOUD_FAILURE === '1',
338
+ debug: (message) => this.logger.debug(message),
339
+ });
340
+ this.codeOperations = new CodeOperationsService((systemPrompt, userPrompt, model, maxTokens) => this.chatComplete(systemPrompt, userPrompt, model, maxTokens));
341
+ this.mcpContextClient = new McpContextClient({
342
+ getBaseUrls: () => this.getMcpBaseUrls(),
343
+ getAccessToken: () => this.getAccessToken(),
344
+ fetch: (input, init) => this.fetch(input, init),
345
+ sanitizeError: sanitizeUserFacingErrorText,
346
+ debug: (message, detail) => this.logger.debug(message, detail),
347
+ });
348
+ this.v3AgentClient = new V3AgentClient({
349
+ getConfiguredApiUrl: () => String(this.config.get('apiUrl') || 'https://coder.vigthoria.io'),
350
+ getAccessToken: () => this.getAccessToken(),
351
+ getServiceKey: () => this.config.get('v3ServiceKey'),
352
+ setServiceKey: (key) => this.config.set('v3ServiceKey', key),
353
+ fetchServiceKey: async (token) => {
354
+ const response = await this.client.get('/api/cli/v3-service-key', {
355
+ headers: { Authorization: `Bearer ${token}` }, timeout: 6000,
356
+ });
357
+ return response.data?.v3ServiceKey || null;
358
+ },
359
+ refreshToken: () => this.refreshToken(),
360
+ fetch: (input, init) => this.fetch(input, init),
361
+ sanitizeError: sanitizeUserFacingErrorText,
362
+ debug: (message) => this.logger.debug(message),
363
+ });
364
+ this.v3WorkspaceService = new V3WorkspaceService({
365
+ resolveTargetPath: (context) => this.resolveAgentTargetPath(context),
366
+ warn: (message) => this.logger.warn(message),
367
+ });
368
+ this.capabilityHealthService = new CapabilityHealthService({
369
+ getCoderHealth: () => this.getCoderHealth(),
370
+ getModelsHealth: () => this.getModelsHealth(),
371
+ getSelfHostedHealth: () => this.getSelfHostedHealth(),
372
+ getV3Health: () => this.getV3AgentHealth(),
373
+ getHyperLoopHealth: () => this.getHyperLoopHealth(),
374
+ getRepoMemoryHealth: (context) => this.getRepoMemoryHealth(context),
375
+ getDevtoolsBridgeHealth: () => this.probeDevtoolsBridgeStatus(),
376
+ modelUsesV3: (requestedModel) => this.modelGovernance.isSelfHostedPreferredModel(this.resolveModelId(requestedModel), requestedModel),
377
+ });
378
+ this.modelTransportService = new ModelTransportService({
379
+ resolveModelId: (model) => this.modelGovernance.resolveModelId(model),
380
+ resolvePermittedModelId: (model) => this.modelGovernance.resolvePermittedModelId(model),
381
+ hasCloudAccess: () => this.config.hasCloudAccess(),
382
+ isAuthenticated: () => this.config.isAuthenticated(),
383
+ isCloudModelId: (model) => this.modelGovernance.isCloudModelId(model),
384
+ shouldSkipCloudRoutes: (model) => this.modelGovernance.shouldSkipCloudRoutes(model),
385
+ isSelfHostedPreferred: (model, requested) => this.modelGovernance.isSelfHostedPreferredModel(model, requested),
386
+ shouldTrySelfHosted: (model, requested) => this.modelGovernance.shouldTrySelfHostedFallback(model, requested),
387
+ getSelfHostedFallback: (model, requested) => this.modelGovernance.getSelfHostedFallbackModelId(model, requested),
388
+ getFallbackModel: (model) => this.modelGovernance.getFallbackModelId(model),
389
+ getMaxTokens: () => this.config.get('preferences').maxTokens,
390
+ hasSelfHostedTransport: () => Boolean(this.selfHostedModelRouterClient),
391
+ postModels: async (body) => (await this.modelRouterClient.post('/v1/chat/completions', body)).data,
392
+ postCoder: async (body) => (await this.client.post('/api/ai/chat', body)).data,
393
+ postCanonicalCoder: async (body) => {
394
+ const endpoint = 'https://coder.vigthoria.io/api/ai/chat';
395
+ const token = this.getAccessToken();
396
+ assertTrustedEndpoint(endpoint, { audience: 'coder', credentialBearing: Boolean(token) });
397
+ return (await axios.post(endpoint, body, {
398
+ timeout: 180000,
399
+ httpsAgent: this._httpsAgent ?? undefined,
400
+ headers: token ? { Authorization: `Bearer ${token}`, Cookie: `vigthoria-auth-token=${token}` } : {},
401
+ signal: this.withLifecycleSignal(),
402
+ maxRedirects: 0,
403
+ })).data;
404
+ },
405
+ postSelfHosted: async (body) => {
406
+ if (!this.selfHostedModelRouterClient)
407
+ throw new Error('Dedicated model transport is unavailable.');
408
+ return (await this.selfHostedModelRouterClient.post('/v1/chat/completions', body, { headers: { 'x-agent-mode': 'true' } })).data;
409
+ },
410
+ debug: (message) => this.logger.debug(message),
411
+ });
412
+ this.operatorClient = new OperatorClient({
413
+ getBaseUrls: () => this.getOperatorBaseUrls(),
414
+ getAuthToken: () => this.config.get('authToken'),
415
+ fetch: (input, init) => this.fetch(input, init),
416
+ sanitizeError: sanitizeUserFacingErrorText,
417
+ });
418
+ this.frontendPreviewService = new FrontendPreviewService({
419
+ getBaseUrls: () => this.getTemplateServiceBaseUrls(),
420
+ getAccessToken: () => this.getAccessToken(),
421
+ fetch: (input, init) => this.fetch(input, init),
422
+ resolveTargetPath: (context) => this.resolveAgentTargetPath(context),
423
+ extractExpectedFiles: (message, context) => this.extractExpectedWorkspaceFiles(message, context),
424
+ isAnalysisOnlyTask: (message, context) => this.isAnalysisOnlyTask(message, context),
425
+ sanitizeError: sanitizeUserFacingErrorText,
426
+ screenshotAdapter: dependencies.screenshotAdapter || new OptionalPuppeteerScreenshotAdapter(),
427
+ });
428
+ this.vigFlowClient = new VigFlowClient({
429
+ getBaseUrls: () => this.getVigFlowBaseUrls(),
430
+ getAccessToken: () => this.getAccessToken(),
431
+ debug: (message, detail) => this.logger.debug(message, detail),
432
+ });
433
+ this.unsubscribeAuthInvalidation = subscribeAuthInvalidation(() => {
434
+ this.vigFlowClient.clearCredentials();
435
+ });
436
+ installGlobalFetchPolicy();
323
437
  // Create HTTPS agent with proper settings for cross-platform compatibility
324
438
  const httpsAgent = new https.Agent({
325
439
  rejectUnauthorized: true, // Enforce SSL verification
@@ -358,11 +472,22 @@ export class APIClient {
358
472
  'User-Agent': `Vigthoria-CLI/${process.env.npm_package_version || '1.6.9'}`,
359
473
  },
360
474
  }) : null;
475
+ installAxiosNetworkPolicy(this.client, 'coder');
476
+ installAxiosNetworkPolicy(this.modelRouterClient, 'models');
477
+ if (this.selfHostedModelRouterClient)
478
+ installAxiosNetworkPolicy(this.selfHostedModelRouterClient, 'models');
479
+ for (const client of [this.client, this.modelRouterClient, this.selfHostedModelRouterClient].filter(Boolean)) {
480
+ client.interceptors.request.use((request) => {
481
+ request.signal = this.withLifecycleSignal(request.signal);
482
+ return request;
483
+ });
484
+ }
361
485
  // Add auth interceptor
362
486
  this.client.interceptors.request.use((req) => {
363
487
  const token = this.getAccessToken();
364
488
  const destination = resolveAxiosRequestUrl(req);
365
- if (token && isTrustedTokenDestination(destination)) {
489
+ if (token) {
490
+ assertTrustedEndpoint(destination, { audience: 'coder', credentialBearing: true });
366
491
  req.headers.Authorization = `Bearer ${token}`;
367
492
  req.headers.Cookie = `vigthoria-auth-token=${token}`;
368
493
  }
@@ -371,7 +496,8 @@ export class APIClient {
371
496
  this.modelRouterClient.interceptors.request.use((req) => {
372
497
  const token = this.getAccessToken();
373
498
  const destination = resolveAxiosRequestUrl(req);
374
- if (token && isTrustedTokenDestination(destination)) {
499
+ if (token) {
500
+ assertTrustedEndpoint(destination, { audience: 'models', credentialBearing: true });
375
501
  req.headers.Authorization = `Bearer ${token}`;
376
502
  req.headers.Cookie = `vigthoria-auth-token=${token}`;
377
503
  }
@@ -380,7 +506,8 @@ export class APIClient {
380
506
  this.selfHostedModelRouterClient?.interceptors.request.use((req) => {
381
507
  const token = this.getAccessToken();
382
508
  const destination = resolveAxiosRequestUrl(req);
383
- if (token && isTrustedTokenDestination(destination)) {
509
+ if (token) {
510
+ assertTrustedEndpoint(destination, { audience: 'models', credentialBearing: true });
384
511
  req.headers.Authorization = `Bearer ${token}`;
385
512
  }
386
513
  return req;
@@ -411,17 +538,13 @@ export class APIClient {
411
538
  * on Windows / Node 25+.
412
539
  */
413
540
  destroy() {
541
+ this.lifecycleAbortController.abort();
542
+ this.unsubscribeAuthInvalidation?.();
543
+ this.unsubscribeAuthInvalidation = null;
414
544
  if (this._httpsAgent) {
415
545
  this._httpsAgent.destroy();
416
546
  this._httpsAgent = null;
417
547
  }
418
- if (this.ws) {
419
- try {
420
- this.ws.close();
421
- }
422
- catch { /* ok */ }
423
- this.ws = null;
424
- }
425
548
  }
426
549
  /** Exposed for Balanced 4B agent routing (local inference :8016). */
427
550
  getSelfHostedModelsApiUrl() {
@@ -441,10 +564,8 @@ export class APIClient {
441
564
  throw new Error(response.data.error || 'Login failed');
442
565
  }
443
566
  const { token, user } = response.data;
444
- this.config.setAuth({
445
- token,
446
- userId: user.id,
447
- email: user.email,
567
+ await this.authSession.validateAndPersistToken(token, {
568
+ identity: { userId: user.id, email: user.email },
448
569
  });
449
570
  // Set subscription from user data
450
571
  this.config.setSubscription({
@@ -463,92 +584,16 @@ export class APIClient {
463
584
  }
464
585
  async loginWithToken(token) {
465
586
  try {
466
- // Validate token by making a request to user info endpoint
467
- this.config.set('authToken', token);
468
- const headers = {
469
- Authorization: `Bearer ${token}`,
470
- Cookie: `vigthoria-auth-token=${token}`,
471
- };
472
- const candidateEndpoints = [
473
- '/api/user/profile',
474
- '/api/user/info',
475
- '/api/auth/me',
476
- ];
477
- for (const endpoint of candidateEndpoints) {
478
- try {
479
- const response = await this.client.get(endpoint, { headers });
480
- const profile = this.extractUserProfile(response.data);
481
- if (!profile) {
482
- continue;
483
- }
484
- this.config.setAuth({
485
- token,
486
- userId: profile.id,
487
- email: profile.email,
488
- });
489
- this.config.setSubscription({
490
- plan: profile.plan,
491
- status: 'active',
492
- expiresAt: undefined,
493
- });
494
- return true;
495
- }
496
- catch (error) {
497
- const axiosError = error;
498
- if (axiosError.response?.status && axiosError.response.status !== 404) {
499
- throw error;
500
- }
501
- }
502
- }
503
- this.config.clearAuth();
504
- return false;
587
+ await this.authSession.validateAndPersistToken(token);
588
+ return true;
505
589
  }
506
590
  catch (error) {
507
- this.config.clearAuth();
508
591
  this.logger.error('Token validation failed:', error.message);
509
592
  return false;
510
593
  }
511
594
  }
512
- extractUserProfile(data) {
513
- if (!data) {
514
- return null;
515
- }
516
- const rawUser = data.user || data;
517
- const userId = rawUser.id;
518
- const email = rawUser.email;
519
- if (!userId || !email) {
520
- return null;
521
- }
522
- const plan = data.subscription?.plan
523
- || rawUser.subscription?.plan
524
- || rawUser.subscription_plan
525
- || data.subscription_plan
526
- || 'community';
527
- return {
528
- id: userId,
529
- email,
530
- plan,
531
- };
532
- }
533
595
  async refreshToken() {
534
- const refreshToken = this.config.get('refreshToken');
535
- if (!refreshToken)
536
- return false;
537
- try {
538
- const response = await this.client.post('/api/token/refresh', {
539
- refresh_token: refreshToken
540
- });
541
- const { token, access_token, refresh_token } = response.data;
542
- this.config.set('authToken', token || access_token);
543
- if (refresh_token) {
544
- this.config.set('refreshToken', refresh_token);
545
- }
546
- return true;
547
- }
548
- catch {
549
- this.config.clearAuth();
550
- return false;
551
- }
596
+ return this.authSession.refresh();
552
597
  }
553
598
  async getSubscriptionStatus() {
554
599
  try {
@@ -565,10 +610,7 @@ export class APIClient {
565
610
  }
566
611
  }
567
612
  getAccessToken() {
568
- return process.env.VIGTHORIA_TOKEN
569
- || process.env.VIGTHORIA_AUTH_TOKEN
570
- || this.config.get('authToken')
571
- || null;
613
+ return this.authSession.getAccessToken();
572
614
  }
573
615
  /**
574
616
  * Validate the current auth token against the Coder API.
@@ -648,63 +690,22 @@ export class APIClient {
648
690
  return { valid: true };
649
691
  }
650
692
  isDirectV3AgentBaseUrl(baseUrl) {
651
- try {
652
- const parsed = new URL(String(baseUrl));
653
- return parsed.port === '8030' || parsed.port.endsWith('8030');
654
- }
655
- catch {
656
- return /(?:127\.0\.0\.1|localhost|172\.19\.0\.1|host\.lan):(?:\d*8030|8030)/.test(String(baseUrl));
657
- }
693
+ return this.v3AgentClient.isDirectBaseUrl(baseUrl);
658
694
  }
659
695
  getV3AgentBaseUrls(preferLocal = false) {
660
- const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
661
- const localTestMode = isLocalTestfarmMode();
662
- const explicitV3EndpointConfigured = Boolean(process.env.VIGTHORIA_V3_AGENT_URL || process.env.V3_AGENT_URL);
663
- const includeLoopbackV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1'
664
- || (preferLocal && isServerRuntime());
665
- const localCandidates = [
666
- process.env.VIGTHORIA_V3_AGENT_URL,
667
- process.env.V3_AGENT_URL,
668
- ...(includeLoopbackV3Agent ? ['http://127.0.0.1:8030'] : []),
669
- ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
670
- const remoteCandidates = localTestMode
671
- ? [configuredApiUrl]
672
- : [configuredApiUrl, 'https://coder.vigthoria.io'];
673
- const normalizedRemote = remoteCandidates
674
- .filter(Boolean)
675
- .map((url) => String(url).replace(/\/$/, ''));
676
- // An explicit V3 endpoint is an operator routing decision, not a fallback.
677
- // Keep it ahead of the public Coder relay so stale remote user credentials
678
- // cannot abort a healthy service-key-authenticated internal CLI run.
679
- const urls = (preferLocal || localTestMode || explicitV3EndpointConfigured) && localCandidates.length > 0
680
- ? [...localCandidates, ...normalizedRemote]
681
- : [...normalizedRemote, ...localCandidates];
682
- return [...new Set(urls)];
696
+ return this.v3AgentClient.getBaseUrls(preferLocal);
683
697
  }
684
698
  getV3AgentRunUrl(baseUrl) {
685
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
686
- return `${baseUrl}/api/agent/run`;
687
- }
688
- return `${baseUrl}/api/v3-agent/run`;
699
+ return this.v3AgentClient.runUrl(baseUrl);
689
700
  }
690
701
  getV3AgentContinueUrl(baseUrl) {
691
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
692
- return `${baseUrl}/api/agent/continue`;
693
- }
694
- return `${baseUrl}/api/v3-agent/continue`;
702
+ return this.v3AgentClient.continueUrl(baseUrl);
695
703
  }
696
704
  getV3AgentBackgroundUrl(baseUrl, suffix = '') {
697
- const cleanSuffix = suffix ? `/${suffix.replace(/^\/+/, '')}` : '';
698
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
699
- return `${baseUrl}/api/agent/background${cleanSuffix}`;
700
- }
701
- return `${baseUrl}/api/v3-agent/background${cleanSuffix}`;
705
+ return this.v3AgentClient.backgroundUrl(baseUrl, suffix);
702
706
  }
703
707
  getV3AgentApprovalUrl(baseUrl) {
704
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
705
- return `${baseUrl}/api/agent/approval`;
706
- }
707
- return `${baseUrl}/api/v3-agent/approval`;
708
+ return this.v3AgentClient.approvalUrl(baseUrl);
708
709
  }
709
710
  getOperatorBaseUrls() {
710
711
  const configuredModelsApiUrl = String(this.config.get('modelsApiUrl') || 'https://api.vigthoria.io').replace(/\/$/, '');
@@ -717,9 +718,6 @@ export class APIClient {
717
718
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
718
719
  return [...new Set(urls)];
719
720
  }
720
- getOperatorStreamUrl(baseUrl) {
721
- return `${baseUrl}/api/operator/stream`;
722
- }
723
721
  getMcpBaseUrls() {
724
722
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
725
723
  const localTestMode = isLocalTestfarmMode();
@@ -765,22 +763,6 @@ export class APIClient {
765
763
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
766
764
  return [...new Set(urls)];
767
765
  }
768
- isFrontendTask(message = '', context = {}) {
769
- // Never treat analysis-only tasks as frontend tasks — preview gate
770
- // should not fire for read-only inspection prompts.
771
- if (this.isAnalysisOnlyTask(message, context)) {
772
- return false;
773
- }
774
- const prompt = String(message || '');
775
- const expectedFiles = this.extractExpectedWorkspaceFiles(message, context);
776
- const hasHtmlEntry = expectedFiles.some((filePath) => /\.html?$/i.test(filePath));
777
- return /(premium|polished|landing|site|page|dashboard|saas|frontend|ui|pricing|showcase|hero|responsive)/i.test(prompt)
778
- || hasHtmlEntry;
779
- }
780
- /**
781
- * Returns true when the prompt describes a read-only / analysis task.
782
- * Used to suppress preview gate and other write-oriented side effects.
783
- */
784
766
  isAnalysisOnlyTask(message = '', context = {}) {
785
767
  const prompt = String(message || '').toLowerCase();
786
768
  const taskType = String(context.agentTaskType || '').toLowerCase();
@@ -790,801 +772,51 @@ export class APIClient {
790
772
  return true;
791
773
  return /^(what|which|how many|list|show|check|inspect|analyze|analyse|audit|explain|describe|summarize|summarise|review|overview|find|count|read|look at|tell me|diagnos)/i.test(prompt.trim());
792
774
  }
775
+ runTemplateServicePreviewGate(message = '', context = {}) {
776
+ return this.frontendPreviewService.runTemplateServicePreviewGate(message, context);
777
+ }
793
778
  normalizeWorkspaceRelativePath(filePath) {
794
- return String(filePath || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
779
+ return this.frontendPreviewService.normalizeWorkspaceRelativePath(filePath);
795
780
  }
796
781
  collectAgentStateSyncPaths(rootPath) {
797
- const paths = [];
798
- const progressPath = path.join(rootPath, '.vigthoria', 'agent-state', 'progress.json');
799
- if (!fs.existsSync(progressPath)) {
800
- return paths;
801
- }
802
- paths.push('.vigthoria/agent-state/progress.json');
803
- const tasksDir = path.join(rootPath, '.vigthoria', 'agent-state', 'tasks');
804
- if (!fs.existsSync(tasksDir)) {
805
- return paths;
806
- }
807
- try {
808
- for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
809
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
810
- continue;
811
- }
812
- paths.push(`.vigthoria/agent-state/tasks/${entry.name}`);
813
- }
814
- }
815
- catch {
816
- // Ignore unreadable task snapshots.
817
- }
818
- return paths;
819
- }
820
- listFrontendWorkspaceFiles(rootPath) {
821
- const results = [];
822
- const stack = [rootPath];
823
- while (stack.length > 0) {
824
- const current = stack.pop();
825
- if (!current) {
826
- continue;
827
- }
828
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
829
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.vigthoria') {
830
- continue;
831
- }
832
- const fullPath = path.join(current, entry.name);
833
- if (entry.isDirectory()) {
834
- stack.push(fullPath);
835
- continue;
836
- }
837
- if (!entry.isFile()) {
838
- continue;
839
- }
840
- const relativePath = this.normalizeWorkspaceRelativePath(path.relative(rootPath, fullPath));
841
- if (relativePath) {
842
- results.push(relativePath);
843
- }
844
- }
845
- }
846
- results.sort((left, right) => left.localeCompare(right));
847
- return results;
848
- }
849
- chooseFrontendPreviewEntry(rootPath, htmlPaths, expectedFiles = []) {
850
- const normalizedExpected = expectedFiles
851
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
852
- .filter((filePath) => /\.html?$/i.test(filePath));
853
- for (const expectedPath of normalizedExpected) {
854
- if (htmlPaths.includes(expectedPath)) {
855
- return expectedPath;
856
- }
857
- }
858
- const scored = htmlPaths.map((relativePath) => {
859
- const baseName = path.posix.basename(relativePath).toLowerCase();
860
- const depth = relativePath.split('/').length - 1;
861
- let score = 0;
862
- const normalizedPath = relativePath.toLowerCase();
863
- const topLevelDir = normalizedPath.split('/')[0];
864
- if (['dist', 'build', 'out'].includes(topLevelDir)) {
865
- score += 320;
866
- }
867
- else if (topLevelDir === 'public') {
868
- score += 180;
869
- }
870
- if (normalizedPath === 'index.html') {
871
- score += 200;
872
- }
873
- if (baseName === 'index.html') {
874
- score += 120;
875
- }
876
- if (/(landing|home|main|dashboard|app)/i.test(baseName)) {
877
- score += 60;
878
- }
879
- if (depth === 0) {
880
- score += 40;
881
- }
882
- return { relativePath, score, depth };
883
- }).sort((left, right) => right.score - left.score || left.depth - right.depth || left.relativePath.localeCompare(right.relativePath));
884
- return scored[0]?.relativePath || null;
782
+ return this.frontendPreviewService.collectAgentStateSyncPaths(rootPath);
885
783
  }
886
784
  extractLinkedFrontendAssets(html, entryPath) {
887
- const css = new Set();
888
- const js = new Set();
889
- const assets = new Set();
890
- const entryDir = path.posix.dirname(this.normalizeWorkspaceRelativePath(entryPath) || '.');
891
- const normalizeAsset = (assetPath) => {
892
- const clean = String(assetPath || '').trim();
893
- if (!clean || clean.startsWith('http://') || clean.startsWith('https://') || clean.startsWith('//') || clean.startsWith('data:') || clean.startsWith('#')) {
894
- return null;
895
- }
896
- const withoutQuery = clean.split('?')[0].split('#')[0];
897
- if (!withoutQuery) {
898
- return null;
899
- }
900
- if (withoutQuery.startsWith('/')) {
901
- // Preview/runtime serve a nested HTML entry from its containing
902
- // directory, so `/styles.css` beside `showcase/index.html` resolves to
903
- // `showcase/styles.css` in the hydrated workspace.
904
- return this.normalizeWorkspaceRelativePath(path.posix.normalize(path.posix.join(entryDir, withoutQuery.slice(1))));
905
- }
906
- return this.normalizeWorkspaceRelativePath(path.posix.normalize(path.posix.join(entryDir, withoutQuery)));
907
- };
908
- const cssPattern = /<link[^>]+href=["']([^"']+\.css(?:[?#][^"']*)?)["'][^>]*>/gi;
909
- const jsPattern = /<script[^>]+src=["']([^"']+\.(?:js|mjs|cjs)(?:[?#][^"']*)?)["'][^>]*>/gi;
910
- let match;
911
- while ((match = cssPattern.exec(String(html || ''))) !== null) {
912
- const resolved = normalizeAsset(match[1]);
913
- if (resolved) {
914
- css.add(resolved);
915
- }
916
- }
917
- while ((match = jsPattern.exec(String(html || ''))) !== null) {
918
- const resolved = normalizeAsset(match[1]);
919
- if (resolved) {
920
- js.add(resolved);
921
- }
922
- }
923
- const assetPattern = /<(?:link|img|source|video|audio)\b[^>]+(?:href|src)=["']([^"']+)["'][^>]*>/gi;
924
- while ((match = assetPattern.exec(String(html || ''))) !== null) {
925
- const resolved = normalizeAsset(match[1]);
926
- if (resolved) {
927
- assets.add(resolved);
928
- }
929
- }
930
- return {
931
- css: Array.from(css),
932
- js: Array.from(js),
933
- assets: Array.from(assets),
934
- };
935
- }
936
- async buildFrontendForPreview(rootPath) {
937
- const packagePath = path.join(rootPath, 'package.json');
938
- if (!fs.existsSync(packagePath)) {
939
- return { attempted: false, succeeded: false };
940
- }
941
- try {
942
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
943
- if (!packageJson.scripts?.build) {
944
- return { attempted: false, succeeded: false };
945
- }
946
- }
947
- catch (error) {
948
- return { attempted: false, succeeded: false, error: `Invalid package.json: ${error?.message || String(error)}` };
949
- }
950
- const npmCommand = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
951
- const npmArgs = process.platform === 'win32'
952
- ? ['/d', '/s', '/c', 'npm run build']
953
- : ['run', 'build'];
954
- return await new Promise((resolve) => {
955
- execFile(npmCommand, npmArgs, { cwd: rootPath, timeout: 150_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }, (error) => {
956
- if (error) {
957
- resolve({ attempted: true, succeeded: false, error: sanitizeUserFacingErrorText(error.message).slice(0, 300) });
958
- return;
959
- }
960
- resolve({ attempted: true, succeeded: true });
961
- });
962
- });
963
- }
964
- gatherFrontendPreviewArtifacts(message = '', context = {}) {
965
- const rootPath = this.resolveAgentTargetPath(context);
966
- if (!rootPath || !fs.existsSync(rootPath)) {
967
- return { error: 'Frontend preview gate requires a readable project workspace.' };
968
- }
969
- const workspaceFiles = this.listFrontendWorkspaceFiles(rootPath);
970
- const expectedFiles = this.extractExpectedWorkspaceFiles(message, context);
971
- const htmlPaths = workspaceFiles.filter((filePath) => /\.html?$/i.test(filePath));
972
- if (htmlPaths.length === 0) {
973
- return { error: 'Frontend preview gate requires at least one HTML entry file in the workspace.' };
974
- }
975
- const htmlPath = this.chooseFrontendPreviewEntry(rootPath, htmlPaths, expectedFiles);
976
- if (!htmlPath) {
977
- return { error: 'Frontend preview gate could not determine a primary HTML entry file.' };
978
- }
979
- const html = fs.readFileSync(path.join(rootPath, htmlPath), 'utf8');
980
- const linkedAssets = this.extractLinkedFrontendAssets(html, htmlPath);
981
- const cssFiles = workspaceFiles.filter((filePath) => /\.css$/i.test(filePath));
982
- const jsFiles = workspaceFiles.filter((filePath) => /\.(?:js|mjs|cjs)$/i.test(filePath));
983
- const expectedCss = expectedFiles
984
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
985
- .filter((filePath) => /\.css$/i.test(filePath));
986
- const expectedJs = expectedFiles
987
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
988
- .filter((filePath) => /\.(?:js|mjs|cjs)$/i.test(filePath));
989
- const chooseExisting = (candidates, fallback) => {
990
- const selected = new Set();
991
- for (const candidate of [...candidates, ...fallback]) {
992
- const normalized = this.normalizeWorkspaceRelativePath(candidate);
993
- if (!normalized || selected.has(normalized)) {
994
- continue;
995
- }
996
- if (!fs.existsSync(path.join(rootPath, normalized))) {
997
- continue;
998
- }
999
- selected.add(normalized);
1000
- }
1001
- return Array.from(selected);
1002
- };
1003
- const selectedCssFiles = chooseExisting(linkedAssets.css, [...expectedCss, ...cssFiles]).slice(0, 12);
1004
- const selectedJsFiles = chooseExisting(linkedAssets.js, [...expectedJs, ...jsFiles]).slice(0, 12);
1005
- const css = selectedCssFiles.map((filePath) => fs.readFileSync(path.join(rootPath, filePath), 'utf8')).join('\n\n');
1006
- const js = selectedJsFiles.map((filePath) => fs.readFileSync(path.join(rootPath, filePath), 'utf8')).join('\n\n');
1007
- return { htmlPath, html, css, js, cssPaths: selectedCssFiles, jsPaths: selectedJsFiles };
1008
- }
1009
- async captureFrontendPreviewScreenshot(entryAbsolutePath, screenshotPath) {
1010
- try {
1011
- const puppeteerModule = await import('puppeteer').catch(() => null);
1012
- const puppeteer = puppeteerModule?.default || puppeteerModule;
1013
- if (!puppeteer || typeof puppeteer.launch !== 'function') {
1014
- return { captured: false, error: 'puppeteer-unavailable' };
1015
- }
1016
- const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
1017
- try {
1018
- const page = await browser.newPage();
1019
- await page.setViewport({ width: 1440, height: 960, deviceScaleFactor: 1 });
1020
- await page.goto(`file://${entryAbsolutePath}`, { waitUntil: 'networkidle0', timeout: 20000 });
1021
- await page.screenshot({ path: screenshotPath, fullPage: true });
1022
- }
1023
- finally {
1024
- await browser.close();
1025
- }
1026
- return { captured: true };
1027
- }
1028
- catch (error) {
1029
- return { captured: false, error: error?.message || String(error) };
1030
- }
785
+ return this.frontendPreviewService.extractLinkedFrontendAssets(html, entryPath);
1031
786
  }
1032
- evaluateFrontendVisualProof(summary = {}, artifacts = {}) {
1033
- const reasons = [];
1034
- const cssPaths = Array.isArray(artifacts.cssPaths) ? artifacts.cssPaths : [];
1035
- const html = String(artifacts.html || '');
1036
- const css = String(artifacts.css || '');
1037
- const js = String(artifacts.js || '');
1038
- if (!summary.hasViewportMeta) {
1039
- reasons.push('missing viewport metadata');
1040
- }
1041
- const hasStyleEvidence = cssPaths.length > 0 || /<style[\s>]/i.test(html) || css.trim().length > 0;
1042
- if (!hasStyleEvidence) {
1043
- reasons.push('missing stylesheet or inline style evidence');
1044
- }
1045
- const hasInteractionOrResponsiveEvidence = Boolean(summary.hasResponsiveSignals) || Boolean(summary.hasInteractiveSignals) || /<script[\s>]/i.test(html) || js.trim().length > 0;
1046
- if (!hasInteractionOrResponsiveEvidence) {
1047
- reasons.push('missing responsive or interactive evidence');
1048
- }
1049
- return {
1050
- ok: reasons.length === 0,
1051
- reasons,
1052
- };
787
+ listVigFlowTemplates(options = {}) {
788
+ return this.vigFlowClient.listTemplates(options);
1053
789
  }
1054
- async persistFrontendPreviewArtifacts(previewGate, rootPath, context = {}) {
1055
- if (!previewGate.required || !previewGate.entryPath) {
1056
- return previewGate;
1057
- }
1058
- try {
1059
- const artifactsDir = path.join(rootPath, '.vigthoria', 'proof', 'preview');
1060
- fs.mkdirSync(artifactsDir, { recursive: true });
1061
- const contextId = String(context.contextId || 'preview').trim() || 'preview';
1062
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1063
- const baseName = `${timestamp}-${contextId}`;
1064
- const manifestPath = path.join(artifactsDir, `${baseName}.json`);
1065
- const screenshotPath = path.join(artifactsDir, `${baseName}.png`);
1066
- const entryAbsolutePath = path.join(rootPath, previewGate.entryPath);
1067
- const previewFileUrl = `file://${entryAbsolutePath}`;
1068
- const screenshot = await this.captureFrontendPreviewScreenshot(entryAbsolutePath, screenshotPath);
1069
- const manifest = {
1070
- schemaVersion: 1,
1071
- createdAt: new Date().toISOString(),
1072
- contextId: context.contextId || null,
1073
- workspaceRoot: rootPath,
1074
- entryPath: previewGate.entryPath,
1075
- previewFileUrl,
1076
- previewCommand: `vigthoria preview -p ${rootPath}`,
1077
- previewGate,
1078
- validationArtifacts: {
1079
- summary: previewGate.summary || {},
1080
- modes: previewGate.modes || {},
1081
- backendUrl: previewGate.backendUrl || null,
1082
- processingTimeMs: previewGate.processingTimeMs || null,
1083
- assetPaths: previewGate.assetPaths || { css: [], js: [] },
1084
- },
1085
- screenshot: {
1086
- path: screenshot.captured ? screenshotPath : null,
1087
- captured: screenshot.captured,
1088
- error: screenshot.error || null,
1089
- },
1090
- };
1091
- fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
1092
- return {
1093
- ...previewGate,
1094
- artifacts: {
1095
- manifestPath,
1096
- screenshotPath: screenshot.captured ? screenshotPath : undefined,
1097
- previewFileUrl,
1098
- screenshotCaptured: screenshot.captured,
1099
- screenshotError: screenshot.error,
1100
- },
1101
- };
1102
- }
1103
- catch (error) {
1104
- return {
1105
- ...previewGate,
1106
- artifacts: {
1107
- ...(previewGate.artifacts || {}),
1108
- screenshotCaptured: false,
1109
- screenshotError: error?.message || String(error),
1110
- },
1111
- };
1112
- }
790
+ listVigFlowWorkflows() {
791
+ return this.vigFlowClient.listWorkflows();
1113
792
  }
1114
- async runTemplateServicePreviewGate(message = '', context = {}) {
1115
- if (!this.isFrontendTask(message, context)) {
1116
- return { required: false, passed: true };
1117
- }
1118
- const rootPath = this.resolveAgentTargetPath(context);
1119
- let artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1120
- let buildResult = null;
1121
- if (rootPath && artifacts.error?.includes('at least one HTML entry file')) {
1122
- buildResult = await this.buildFrontendForPreview(rootPath);
1123
- if (buildResult.attempted) {
1124
- artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1125
- }
1126
- }
1127
- if (artifacts.error || !artifacts.htmlPath || typeof artifacts.html !== 'string') {
1128
- const buildDetail = buildResult?.attempted
1129
- ? ` Build retry ${buildResult.succeeded ? 'completed but produced no HTML entry' : `failed: ${buildResult.error || 'unknown build error'}`}.`
1130
- : '';
1131
- return {
1132
- required: true,
1133
- passed: false,
1134
- error: `${artifacts.error || 'Frontend preview gate could not collect frontend artifacts.'}${buildDetail}`,
1135
- };
1136
- }
1137
- // Cap artifact sizes to prevent 413 Payload Too Large
1138
- const PREVIEW_MAX_ARTIFACT_BYTES = 250 * 1024;
1139
- const html = artifacts.html.slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1140
- const css = (artifacts.css || '').slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1141
- const js = (artifacts.js || '').slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1142
- const errors = [];
1143
- for (const baseUrl of this.getTemplateServiceBaseUrls()) {
1144
- try {
1145
- const proofPayload = JSON.stringify({
1146
- vision: String(context.rawPrompt || message || '').slice(0, 2000),
1147
- html,
1148
- css,
1149
- js,
1150
- entryPath: artifacts.htmlPath,
1151
- workspaceName: path.basename(rootPath),
1152
- });
1153
- // Skip preview proof if payload is still too large (> 4 MB)
1154
- if (Buffer.byteLength(proofPayload, 'utf8') > 4 * 1024 * 1024) {
1155
- return {
1156
- required: true,
1157
- passed: true,
1158
- error: 'Preview proof skipped: payload exceeds size limit.',
1159
- };
1160
- }
1161
- const internalServiceKey = process.env.VIGTHORIA_TEMPLATE_SERVICE_KEY
1162
- || process.env.V3_SERVICE_KEY;
1163
- const authToken = this.getAccessToken();
1164
- const response = await fetch(`${baseUrl}/preview-proof`, {
1165
- method: 'POST',
1166
- headers: {
1167
- 'Content-Type': 'application/json',
1168
- Accept: 'application/json',
1169
- ...(authToken ? {
1170
- Authorization: `Bearer ${authToken}`,
1171
- Cookie: `vigthoria-auth-token=${authToken}`,
1172
- } : {}),
1173
- ...(internalServiceKey ? { 'x-vigthoria-service-key': internalServiceKey } : {}),
1174
- },
1175
- body: proofPayload,
1176
- });
1177
- if (!response.ok) {
1178
- const errorText = await response.text().catch(() => '');
1179
- throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1180
- }
1181
- const payload = await response.json();
1182
- const modes = payload?.modes || {};
1183
- const summary = payload?.summary || {};
1184
- const visualProof = this.evaluateFrontendVisualProof(summary, artifacts);
1185
- const passed = payload?.success === true
1186
- && modes?.design?.ready === true
1187
- && modes?.live?.ready === true
1188
- && modes?.production?.ready === true
1189
- && visualProof.ok;
1190
- const previewGate = {
1191
- required: true,
1192
- passed,
1193
- backendUrl: baseUrl,
1194
- entryPath: artifacts.htmlPath,
1195
- assetPaths: {
1196
- css: artifacts.cssPaths || [],
1197
- js: artifacts.jsPaths || [],
1198
- },
1199
- modes,
1200
- summary,
1201
- processingTimeMs: Number(payload?.processing_time || 0) || undefined,
1202
- error: passed ? undefined : (visualProof.ok ? 'Template Service preview modes did not all report ready.' : `Frontend preview proof is missing required visual evidence: ${visualProof.reasons.join(', ')}.`),
1203
- };
1204
- return await this.persistFrontendPreviewArtifacts(previewGate, rootPath, context);
1205
- }
1206
- catch (error) {
1207
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
1208
- }
1209
- }
1210
- return await this.persistFrontendPreviewArtifacts({
1211
- required: true,
1212
- passed: false,
1213
- entryPath: artifacts.htmlPath,
1214
- assetPaths: {
1215
- css: artifacts.cssPaths || [],
1216
- js: artifacts.jsPaths || [],
1217
- },
1218
- error: errors.join(' | ') || 'No Template Service preview endpoint was reachable.',
1219
- }, rootPath, context);
793
+ resolveVigFlowWorkflow(selector) {
794
+ return this.vigFlowClient.resolveWorkflow(selector);
1220
795
  }
1221
- getMcpContextCreateUrl(baseUrl) {
1222
- return `${baseUrl}/mcp/context/create`;
796
+ useVigFlowTemplate(templateId, options = {}) {
797
+ return this.vigFlowClient.useTemplate(templateId, options);
1223
798
  }
1224
- getMcpContextUrl(baseUrl, contextId) {
1225
- return `${baseUrl}/mcp/context/${encodeURIComponent(contextId)}`;
799
+ runVigFlowWorkflow(workflowId, options = {}) {
800
+ return this.vigFlowClient.runWorkflow(workflowId, options);
1226
801
  }
1227
- async getMcpHeaders() {
1228
- const headers = {
1229
- 'Content-Type': 'application/json',
1230
- Accept: 'application/json',
1231
- };
1232
- const authToken = this.getAccessToken();
1233
- if (authToken) {
1234
- headers.Authorization = `Bearer ${authToken}`;
1235
- headers.Cookie = `vigthoria-auth-token=${authToken}`;
1236
- headers['X-Vigthoria-Token'] = authToken;
1237
- headers['X-Auth-Token'] = authToken;
1238
- }
1239
- return headers;
802
+ getVigFlowExecutionStatus(executionId) {
803
+ return this.vigFlowClient.executionStatus(executionId);
1240
804
  }
1241
805
  async getV3AgentHeaders() {
1242
- const headers = {
1243
- 'Content-Type': 'application/json',
1244
- Accept: 'text/event-stream',
1245
- };
1246
- const authToken = this.getAccessToken();
1247
- if (authToken) {
1248
- headers.Authorization = `Bearer ${authToken}`;
1249
- headers.Cookie = `vigthoria-auth-token=${authToken}`;
1250
- }
1251
- const serviceKey = process.env.VIGTHORIA_V3_SERVICE_KEY
1252
- || process.env.V3_SERVICE_KEY
1253
- || process.env.HYPERLOOP_SERVICE_KEY
1254
- || this.config.get('v3ServiceKey');
1255
- if (serviceKey) {
1256
- headers['X-Service-Key'] = serviceKey;
1257
- }
1258
- return headers;
806
+ return this.v3AgentClient.headers();
1259
807
  }
1260
- /**
1261
- * Ensure the V3 service key is available in config.
1262
- * If not already set via env or stored config, fetches it from the Coder API
1263
- * using the current user's auth token and caches it for this session and beyond.
1264
- */
1265
808
  async ensureV3ServiceKey() {
1266
- const existing = process.env.VIGTHORIA_V3_SERVICE_KEY
1267
- || process.env.V3_SERVICE_KEY
1268
- || process.env.HYPERLOOP_SERVICE_KEY
1269
- || this.config.get('v3ServiceKey');
1270
- if (existing)
1271
- return;
1272
- try {
1273
- const token = this.getAccessToken();
1274
- if (!token)
1275
- return;
1276
- const response = await this.client.get('/api/cli/v3-service-key', {
1277
- headers: { Authorization: `Bearer ${token}` },
1278
- timeout: 6000,
1279
- });
1280
- const key = response.data?.v3ServiceKey;
1281
- if (key) {
1282
- this.config.set('v3ServiceKey', key);
1283
- this.logger.debug('V3 service key retrieved and cached from Coder API.');
1284
- }
1285
- }
1286
- catch {
1287
- // Non-fatal: fall through; the health check will surface the auth error.
1288
- }
809
+ return this.v3AgentClient.ensureServiceKey();
1289
810
  }
1290
811
  async runV3HealthCheck() {
1291
- const endpoints = this.getV3AgentBaseUrls(false);
1292
- const headers = await this.getV3AgentHeaders();
1293
- const failures = [];
1294
- for (const baseUrl of endpoints) {
1295
- const runUrl = this.getV3AgentRunUrl(baseUrl);
1296
- // Use the lightweight GET /health endpoint — no LLM invocation, responds instantly.
1297
- // For local 8030 the path is /health directly; for remote it's /api/v3-agent/health via proxy.
1298
- const healthUrl = this.isDirectV3AgentBaseUrl(baseUrl)
1299
- ? `${baseUrl}/health`
1300
- : `${baseUrl}/api/v3-agent/health`;
1301
- for (let attempt = 1; attempt <= 3; attempt += 1) {
1302
- try {
1303
- const response = await fetch(healthUrl, {
1304
- method: 'GET',
1305
- headers,
1306
- signal: AbortSignal.timeout(8000),
1307
- });
1308
- if (response.status === 401 || response.status === 403) {
1309
- return {
1310
- healthy: false,
1311
- endpoint: runUrl,
1312
- error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1313
- };
1314
- }
1315
- // 200 OK or 405 Method Not Allowed both mean V3 is reachable.
1316
- if (response.ok || response.status === 405) {
1317
- return { healthy: true, endpoint: runUrl };
1318
- }
1319
- failures.push(`${healthUrl} attempt ${attempt}: HTTP ${response.status}`);
1320
- }
1321
- catch (error) {
1322
- const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 160);
1323
- failures.push(`${healthUrl} attempt ${attempt}: ${reason || 'network error'}`);
1324
- }
1325
- if (attempt < 3) {
1326
- await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
1327
- }
1328
- }
1329
- }
1330
- return {
1331
- healthy: false,
1332
- endpoint: endpoints[0] ? this.getV3AgentRunUrl(endpoints[0]) : 'unknown',
1333
- error: `V3 service is not reachable during startup preflight. Checked ${endpoints.length} endpoint(s). ${failures.slice(-3).join(' | ')}`,
1334
- };
812
+ return this.v3AgentClient.health();
1335
813
  }
1336
- async runV3AgentAuthPreflight(baseUrl, body, executionContext) {
1337
- const endpoint = this.getV3AgentRunUrl(baseUrl);
1338
- const healthEndpoint = this.isDirectV3AgentBaseUrl(baseUrl)
1339
- ? `${baseUrl}/health`
1340
- : `${baseUrl}/api/v3-agent/health`;
1341
- const headers = await this.getV3AgentHeaders();
1342
- headers['X-Vigthoria-Preflight'] = '1';
1343
- if (executionContext.mcpContextId) {
1344
- headers['X-MCP-Context-Id'] = String(executionContext.mcpContextId);
1345
- }
1346
- const controller = new AbortController();
1347
- const timeoutId = setTimeout(() => controller.abort(), 12000);
1348
- try {
1349
- const response = await fetch(healthEndpoint, {
1350
- method: 'GET',
1351
- headers,
1352
- signal: controller.signal,
1353
- });
1354
- if (response.status === 401 || response.status === 403) {
1355
- const errorText = await response.text().catch(() => '');
1356
- return {
1357
- ok: false,
1358
- status: response.status,
1359
- endpoint: healthEndpoint,
1360
- reason: sanitizeUserFacingErrorText(errorText).slice(0, 200) || 'Unauthorized',
1361
- };
1362
- }
1363
- // Any non-auth response means token acceptance passed at the V3 service layer.
1364
- return { ok: true, status: response.status, endpoint: healthEndpoint };
1365
- }
1366
- catch (error) {
1367
- const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 200);
1368
- // Preflight should not block agent execution on transient timeout/abort network conditions.
1369
- if (/aborted|timeout|timed out|network/i.test(reason)) {
1370
- return {
1371
- ok: true,
1372
- status: 0,
1373
- endpoint: healthEndpoint,
1374
- reason,
1375
- };
1376
- }
1377
- return {
1378
- ok: false,
1379
- status: 0,
1380
- endpoint: healthEndpoint,
1381
- reason,
1382
- };
1383
- }
1384
- finally {
1385
- clearTimeout(timeoutId);
1386
- }
814
+ runV3AgentAuthPreflight(baseUrl, executionContext) {
815
+ return this.v3AgentClient.preflight(baseUrl, executionContext);
1387
816
  }
1388
- async executeV3AgentRunRequest(baseUrl, body, executionContext, signal) {
1389
- const makeRequest = async () => {
1390
- const headers = await this.getV3AgentHeaders();
1391
- if (executionContext.mcpContextId) {
1392
- headers['X-MCP-Context-Id'] = String(executionContext.mcpContextId);
1393
- }
1394
- return fetch(this.getV3AgentRunUrl(baseUrl), {
1395
- method: 'POST',
1396
- headers,
1397
- body: JSON.stringify(body),
1398
- signal,
1399
- });
1400
- };
1401
- let response = await makeRequest();
1402
- if (response.status !== 401) {
1403
- return response;
1404
- }
1405
- const refreshed = await this.refreshToken();
1406
- if (!refreshed) {
1407
- return response;
1408
- }
1409
- response = await makeRequest();
1410
- return response;
817
+ executeV3AgentRunRequest(baseUrl, body, executionContext, signal) {
818
+ return this.v3AgentClient.run(baseUrl, body, executionContext, signal);
1411
819
  }
1412
- async getVigFlowAccessToken(baseUrl) {
1413
- const cachedToken = this.vigFlowTokens.get(baseUrl);
1414
- if (cachedToken) {
1415
- return cachedToken;
1416
- }
1417
- const authToken = this.getAccessToken();
1418
- if (!authToken) {
1419
- throw new Error('Not authenticated. Run vigthoria login first.');
1420
- }
1421
- const response = await axios.post(`${baseUrl}/api/auth/sso`, { token: authToken }, {
1422
- headers: {
1423
- 'Content-Type': 'application/json',
1424
- },
1425
- timeout: 30000,
1426
- });
1427
- const payload = response.data;
1428
- const vigFlowToken = String(payload.token || '').trim();
1429
- if (!vigFlowToken) {
1430
- throw new Error('VigFlow SSO response did not include a token.');
1431
- }
1432
- this.vigFlowTokens.set(baseUrl, vigFlowToken);
1433
- return vigFlowToken;
1434
- }
1435
- async getVigFlowHeaders(baseUrl) {
1436
- const token = await this.getVigFlowAccessToken(baseUrl);
1437
- return {
1438
- 'Content-Type': 'application/json',
1439
- Accept: 'application/json',
1440
- Authorization: `Bearer ${token}`,
1441
- };
1442
- }
1443
- async withVigFlow(operation, action) {
1444
- let lastError = null;
1445
- for (const baseUrl of this.getVigFlowBaseUrls()) {
1446
- try {
1447
- const headers = await this.getVigFlowHeaders(baseUrl);
1448
- return await action(baseUrl, headers);
1449
- }
1450
- catch (error) {
1451
- lastError = error;
1452
- this.logger.debug(`VigFlow ${operation} via ${baseUrl} failed:`, lastError.message);
1453
- }
1454
- }
1455
- // Throw a clean message instead of the raw ECONNREFUSED from the last URL tried
1456
- throw new Error(`No VigFlow backend available for ${operation}. The workflow service is not deployed or not reachable.`);
1457
- }
1458
- /**
1459
- * Build the correct sub-path for VigFlow endpoints.
1460
- * Local servers (e.g. localhost:5060) need `/api/…` prefix.
1461
- * The remote gateway URL already ends with `/api/vigflow`, so appending
1462
- * another `/api/…` would double the prefix and cause 404s.
1463
- */
1464
- vigFlowEndpoint(baseUrl, subPath) {
1465
- if (/\/api\/vigflow\/?$/i.test(baseUrl)) {
1466
- // Remote gateway – subPath like '/templates' is enough
1467
- return `${baseUrl.replace(/\/$/, '')}${subPath}`;
1468
- }
1469
- return `${baseUrl}/api${subPath}`;
1470
- }
1471
- async listVigFlowTemplates(options = {}) {
1472
- return this.withVigFlow('list templates', async (baseUrl, headers) => {
1473
- const query = new URLSearchParams();
1474
- if (options.category) {
1475
- query.set('category', options.category);
1476
- }
1477
- if (options.search) {
1478
- query.set('search', options.search);
1479
- }
1480
- const url = `${this.vigFlowEndpoint(baseUrl, '/templates')}${query.size > 0 ? `?${query.toString()}` : ''}`;
1481
- const response = await axios.get(url, {
1482
- headers,
1483
- timeout: 30000,
1484
- });
1485
- const payload = response.data;
1486
- return Array.isArray(payload.templates) ? payload.templates : [];
1487
- });
1488
- }
1489
- async listVigFlowWorkflows() {
1490
- return this.withVigFlow('list workflows', async (baseUrl, headers) => {
1491
- const response = await axios.get(this.vigFlowEndpoint(baseUrl, '/workflows'), {
1492
- headers,
1493
- timeout: 30000,
1494
- });
1495
- const payload = response.data;
1496
- return Array.isArray(payload.workflows) ? payload.workflows : [];
1497
- });
1498
- }
1499
- async resolveVigFlowWorkflow(selector) {
1500
- const normalizedSelector = String(selector || '').trim();
1501
- if (!normalizedSelector) {
1502
- throw new Error('Workflow selector is required. Provide a workflow id or name.');
1503
- }
1504
- const workflows = await this.listVigFlowWorkflows();
1505
- const byId = workflows.find((workflow) => workflow.id === normalizedSelector);
1506
- if (byId) {
1507
- return {
1508
- id: byId.id,
1509
- name: byId.name,
1510
- selector: normalizedSelector,
1511
- matchedBy: 'id',
1512
- };
1513
- }
1514
- const loweredSelector = normalizedSelector.toLowerCase();
1515
- const exactNameMatches = workflows.filter((workflow) => String(workflow.name || '').trim().toLowerCase() === loweredSelector);
1516
- if (exactNameMatches.length === 1) {
1517
- return {
1518
- id: exactNameMatches[0].id,
1519
- name: exactNameMatches[0].name,
1520
- selector: normalizedSelector,
1521
- matchedBy: 'name',
1522
- };
1523
- }
1524
- if (exactNameMatches.length > 1) {
1525
- throw new Error(`Multiple workflows matched the name "${normalizedSelector}". Use the workflow id instead.`);
1526
- }
1527
- const searchMatches = workflows.filter((workflow) => String(workflow.name || '').toLowerCase().includes(loweredSelector));
1528
- if (searchMatches.length === 1) {
1529
- return {
1530
- id: searchMatches[0].id,
1531
- name: searchMatches[0].name,
1532
- selector: normalizedSelector,
1533
- matchedBy: 'search',
1534
- };
1535
- }
1536
- if (searchMatches.length > 1) {
1537
- throw new Error(`Multiple workflows partially matched "${normalizedSelector}". Use a full workflow name or id.`);
1538
- }
1539
- throw new Error(`No VigFlow workflow matched "${normalizedSelector}".`);
1540
- }
1541
- async useVigFlowTemplate(templateId, options = {}) {
1542
- return this.withVigFlow('use template', async (baseUrl, headers) => {
1543
- const response = await axios.post(`${this.vigFlowEndpoint(baseUrl, `/templates/${encodeURIComponent(templateId)}/use`)}`, {
1544
- name: options.name,
1545
- variables: options.variables || {},
1546
- }, {
1547
- headers,
1548
- timeout: 30000,
1549
- });
1550
- const payload = response.data;
1551
- if (!payload.workflow?.id) {
1552
- throw new Error('VigFlow use-template response did not include a workflow id.');
1553
- }
1554
- return payload.workflow;
1555
- });
1556
- }
1557
- async runVigFlowWorkflow(workflowId, options = {}) {
1558
- return this.withVigFlow('run workflow', async (baseUrl, headers) => {
1559
- const response = await axios.post(`${this.vigFlowEndpoint(baseUrl, `/executions/run/${encodeURIComponent(workflowId)}`)}`, {
1560
- data: options.data || {},
1561
- options: options.executionOptions || {},
1562
- }, {
1563
- headers,
1564
- timeout: 60000,
1565
- });
1566
- const payload = response.data;
1567
- if (!payload.executionId) {
1568
- throw new Error('VigFlow run response did not include an execution id.');
1569
- }
1570
- return payload;
1571
- });
1572
- }
1573
- async getVigFlowExecutionStatus(executionId) {
1574
- return this.withVigFlow('execution status', async (baseUrl, headers) => {
1575
- const response = await axios.get(`${this.vigFlowEndpoint(baseUrl, `/executions/${encodeURIComponent(executionId)}`)}`, {
1576
- headers,
1577
- timeout: 30000,
1578
- });
1579
- const payload = response.data;
1580
- if (!payload.execution?.id) {
1581
- throw new Error('VigFlow execution response did not include execution details.');
1582
- }
1583
- return payload.execution;
1584
- });
1585
- }
1586
- /** Legacy cap — prefer resolveV3ContextCharLimit() for token-aligned budgets. */
1587
- static V3_CONTEXT_CHAR_LIMIT = 100_000;
1588
820
  static HYDRATION_PRIORITY_BASENAMES = new Set([
1589
821
  'package.json', 'index.html', 'game.js', 'main.js', 'app.js', 'script.js',
1590
822
  'styles.css', 'style.css', 'README.md', 'manifest.json',
@@ -1618,18 +850,10 @@ export class APIClient {
1618
850
  if (used + entryLen > budgetChars) {
1619
851
  const normalizedPath = this.normalizeWorkspaceRelativePath(filePath);
1620
852
  if (mandatory.has(normalizedPath)) {
1621
- // Explicit prompt targets are hydration requirements, not ranking
1622
- // hints. Skipping an HTML entry merely because a larger targeted
1623
- // stylesheet consumed the provisional budget creates false preview
1624
- // failures in the remote V3 workspace. Keep the complete explicit
1625
- // file and let later context-compaction phases shed history/metadata.
1626
853
  trimmed[filePath] = clipped;
1627
854
  used += entryLen;
1628
855
  }
1629
856
  else if (explicitPriority.has(normalizedPath)) {
1630
- // Linked preview assets are important enough to hydrate, but unlike
1631
- // edit targets they may be compacted. Keep a bounded prefix so the
1632
- // remote static server resolves the asset instead of returning 404.
1633
857
  const envelope = JSON.stringify(filePath).length + 128;
1634
858
  const available = Math.max(256, budgetChars - used - envelope);
1635
859
  const dependencyClip = content.length > available
@@ -1647,7 +871,6 @@ export class APIClient {
1647
871
  }
1648
872
  buildV3AgentContext(context = {}) {
1649
873
  const resolvedContext = this.ensureExecutionContext(context);
1650
- const targetPath = resolvedContext.targetPath || resolvedContext.projectPath || resolvedContext.workspacePath || resolvedContext.projectRoot || process.cwd();
1651
874
  const localWorkspacePath = this.resolveAgentTargetPath(resolvedContext);
1652
875
  const serverWorkspacePath = this.resolveServerBindableWorkspacePath(resolvedContext);
1653
876
  const promptFocus = String(resolvedContext.rawPrompt || resolvedContext.contextualPrompt || resolvedContext.prompt || '');
@@ -1666,10 +889,6 @@ export class APIClient {
1666
889
  const localWorkspaceRef = localWorkspaceName ? `vigthoria://local-workspace/${localWorkspaceName}` : null;
1667
890
  const executionSurface = String(resolvedContext.executionSurface || resolvedContext.clientSurface || 'cli');
1668
891
  const localMachineCapable = resolvedContext.localMachineCapable !== false;
1669
- // A workspace under a configured server root is already available to the
1670
- // V3 service. Keep tool execution on the server in that case; delegating
1671
- // tools back to the CLI can turn server paths into invalid client paths.
1672
- // An explicit caller override remains authoritative for specialist flows.
1673
892
  const clientToolExecution = resolvedContext.clientToolExecution === false
1674
893
  ? false
1675
894
  : (resolvedContext.clientToolExecution === true
@@ -1704,6 +923,11 @@ export class APIClient {
1704
923
  executionHints.requires_file_changes = false;
1705
924
  }
1706
925
  }
926
+ const contextState = resolvedContext.mcpContextId
927
+ ? appendContextLayer(resolvedContext.contextState, 'mcp-context', {
928
+ metadata: { contextId: resolvedContext.mcpContextId, semanticAuthority: 'tool-evidence-only' },
929
+ })
930
+ : resolvedContext.contextState || null;
1707
931
  const payload = {
1708
932
  workspace: this.buildPublicWorkspaceDescriptor(resolvedContext.workspace, {
1709
933
  localWorkspacePath,
@@ -1755,11 +979,13 @@ export class APIClient {
1755
979
  codebaseContext: resolvedContext.codebaseContext || '',
1756
980
  accountBrainContext: resolvedContext.accountBrainContext || '',
1757
981
  projectMemory: resolvedContext.projectMemory || '',
982
+ contextState,
983
+ contextCompatibilityAdapters: contextState?.compatibilityAdapters || null,
1758
984
  clientManifest: buildClientManifest(resolvedContext.clientManifest && typeof resolvedContext.clientManifest === 'object'
1759
985
  ? resolvedContext.clientManifest
1760
986
  : {}),
1761
987
  };
1762
- return this.compactV3Context(payload);
988
+ return this.compactV3Context(scanOutboundContext(payload).value);
1763
989
  }
1764
990
  /**
1765
991
  * Compact a V3 context payload so the serialized JSON stays under
@@ -1892,34 +1118,35 @@ export class APIClient {
1892
1118
  }
1893
1119
  return finish();
1894
1120
  }
1895
- /** Normalize common Unix-only commands before Windows cmd/powershell execution. */
1896
- normalizeWindowsRunCommand(command, serverRoot = '') {
1897
- let cmd = String(command || '').trim();
1121
+ /** Remove server-side workspace aliases before delegated local execution. */
1122
+ normalizePortableRunCommand(command, serverRoot = '') {
1123
+ const cmd = String(command || '').trim();
1898
1124
  if (!cmd)
1899
1125
  return cmd;
1900
- if (serverRoot) {
1901
- const escapedRoot = serverRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1902
- cmd = cmd.replace(new RegExp(`^\\s*cd\\s+(?:[\"']?${escapedRoot}[\"']?)\\s*(?:&&|;)\\s*`, 'i'), '');
1903
- }
1904
- if (/^\/[^\s/]/.test(cmd) && !/^\/[a-z]:/i.test(cmd)) {
1905
- throw new Error('Command starts with "/" which Windows interprets as an invalid switch. Use workspace-relative paths without a leading slash.');
1906
- }
1907
- const replacements = [
1908
- [/^ls(\s|$)/i, 'dir$1'],
1909
- [/^cat\s+/i, 'type '],
1910
- [/^pwd\s*$/i, 'cd'],
1911
- [/^rm\s+-rf?\s+/i, 'rmdir /s /q '],
1912
- [/^rm\s+/i, 'del '],
1913
- [/^cp\s+/i, 'copy '],
1914
- [/^mv\s+/i, 'move '],
1915
- ];
1916
- for (const [pattern, replacement] of replacements) {
1917
- cmd = cmd.replace(pattern, replacement);
1918
- }
1919
- return cmd;
1126
+ const match = cmd.match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&]+))\s*(?:&&|;)\s*([\s\S]+)$/i);
1127
+ if (!match)
1128
+ return cmd;
1129
+ const requestedCwd = String(match[1] || match[2] || match[3] || '').trim().replace(/\\/g, '/').replace(/\/$/, '');
1130
+ const normalizedServerRoot = String(serverRoot || '').trim().replace(/\\/g, '/').replace(/\/$/, '');
1131
+ const workspaceAliases = new Set([
1132
+ '.',
1133
+ 'vigthoria://workspace',
1134
+ 'vigthoria://server-internal',
1135
+ '/vigthoria/workspace',
1136
+ '/workspace',
1137
+ '[internal]',
1138
+ normalizedServerRoot,
1139
+ ].filter(Boolean).map((value) => value.toLowerCase()));
1140
+ // The delegated client already executes with a validated workspace cwd.
1141
+ // Strip only exact root aliases; preserve real relative subdirectory cd
1142
+ // commands so project-specific workflows keep their intended scope.
1143
+ return workspaceAliases.has(requestedCwd.toLowerCase()) ? String(match[4] || '').trim() : cmd;
1920
1144
  }
1921
1145
  resolveRunCommandCwd(rootPath, args, serverRoot) {
1922
- const cwdRaw = args.cwd ?? args.path ?? '.';
1146
+ // run_command owns a cwd field. A model-supplied source `path` belongs to
1147
+ // file tools and must not silently move command execution into that file's
1148
+ // parent directory (which breaks root-level validation scripts).
1149
+ const cwdRaw = args.cwd ?? args.directory ?? '.';
1923
1150
  let target = this.resolveV3ClientToolPath(rootPath, cwdRaw, serverRoot ? [serverRoot] : []);
1924
1151
  if (!target) {
1925
1152
  target = this.resolveV3ClientToolPath(rootPath, '.', serverRoot ? [serverRoot] : []);
@@ -2044,6 +1271,7 @@ export class APIClient {
2044
1271
  requestStartedAt: resolvedContext.requestStartedAt,
2045
1272
  vigthoriaBrain: dedupedBrain,
2046
1273
  vigthoria_brain: dedupedBrain,
1274
+ contextState: resolvedContext.contextState || null,
2047
1275
  });
2048
1276
  }
2049
1277
  extractEmergencyAppName(message = '', fallback = 'Signal Desk') {
@@ -2514,18 +1742,18 @@ menu {
2514
1742
  }
2515
1743
  async bindExecutionContext(context = {}) {
2516
1744
  const executionContext = this.ensureExecutionContext(context);
2517
- const headers = await this.getMcpHeaders();
2518
1745
  const localWorkspacePath = this.resolveAgentTargetPath(executionContext);
2519
1746
  const workspacePath = this.resolveServerBindableWorkspacePath(executionContext);
2520
1747
  const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath, String(executionContext.rawPrompt || executionContext.contextualPrompt || executionContext.prompt || ''));
1748
+ const workspaceAlias = `vigthoria://workspace/${encodeURIComponent(path.basename(localWorkspacePath || workspacePath || 'workspace'))}`;
2521
1749
  const metadata = {
2522
1750
  source: 'vigthoria-cli',
2523
1751
  sharedContextId: executionContext.contextId,
2524
1752
  traceId: executionContext.traceId,
2525
1753
  executionSurface: executionContext.executionSurface || 'cli',
2526
1754
  clientSurface: executionContext.clientSurface || 'cli',
2527
- workspacePath: workspacePath || null,
2528
- localWorkspacePath: localWorkspacePath || null,
1755
+ workspacePath: workspaceAlias,
1756
+ localWorkspacePath: workspaceAlias,
2529
1757
  localWorkspaceSummary,
2530
1758
  runtimeEnvironment: executionContext.agentRuntime || null,
2531
1759
  requestStartedAt: executionContext.requestStartedAt,
@@ -2536,10 +1764,10 @@ menu {
2536
1764
  sharedContextId: executionContext.contextId,
2537
1765
  traceId: executionContext.traceId,
2538
1766
  requestStartedAt: executionContext.requestStartedAt,
2539
- workspacePath: workspacePath || null,
2540
- projectPath: workspacePath || null,
2541
- targetPath: workspacePath || null,
2542
- localWorkspacePath: localWorkspacePath || null,
1767
+ workspacePath: workspaceAlias,
1768
+ projectPath: workspaceAlias,
1769
+ targetPath: workspaceAlias,
1770
+ localWorkspacePath: workspaceAlias,
2543
1771
  localWorkspaceSummary,
2544
1772
  runtimeEnvironment: executionContext.agentRuntime || null,
2545
1773
  activeFile: executionContext.activeFile || null,
@@ -2548,68 +1776,22 @@ menu {
2548
1776
  userId: this.config.get('userId') || null,
2549
1777
  email: this.config.get('email') || null,
2550
1778
  };
2551
- const updateExisting = async (binding) => {
2552
- const response = await fetch(this.getMcpContextUrl(binding.backendUrl, binding.mcpContextId), {
2553
- method: 'PUT',
2554
- headers,
2555
- body: JSON.stringify({ data }),
2556
- });
2557
- if (!response.ok) {
2558
- const errorText = await response.text().catch(() => '');
2559
- throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2560
- }
2561
- return {
2562
- ...executionContext,
2563
- localWorkspacePath: localWorkspacePath || executionContext.localWorkspacePath || null,
2564
- targetPath: localWorkspacePath || executionContext.targetPath || executionContext.projectPath || executionContext.workspacePath || null,
2565
- projectPath: localWorkspacePath || executionContext.projectPath || executionContext.targetPath || executionContext.workspacePath || null,
2566
- workspacePath: localWorkspacePath || executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || null,
2567
- mcpContextId: binding.mcpContextId,
2568
- mcpContextBackendUrl: binding.backendUrl,
2569
- };
2570
- };
2571
- if (executionContext.mcpContextId && executionContext.mcpContextBackendUrl) {
2572
- try {
2573
- return await updateExisting({
2574
- mcpContextId: String(executionContext.mcpContextId),
2575
- backendUrl: String(executionContext.mcpContextBackendUrl),
2576
- });
2577
- }
2578
- catch (error) {
2579
- this.logger.debug('Failed to refresh existing MCP context binding:', error.message);
2580
- }
2581
- }
2582
- for (const baseUrl of this.getMcpBaseUrls()) {
2583
- try {
2584
- const createResponse = await fetch(this.getMcpContextCreateUrl(baseUrl), {
2585
- method: 'POST',
2586
- headers,
2587
- body: JSON.stringify({
2588
- userId: this.config.get('userId') || this.config.get('email') || 'vigthoria-cli',
2589
- metadata,
2590
- }),
2591
- });
2592
- if (!createResponse.ok) {
2593
- const errorText = await createResponse.text().catch(() => '');
2594
- throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2595
- }
2596
- const payload = await createResponse.json();
2597
- const mcpContextId = String(payload.contextId || '').trim();
2598
- if (!mcpContextId) {
2599
- throw new Error('MCP context create response did not include a contextId');
2600
- }
2601
- return await updateExisting({ mcpContextId, backendUrl: baseUrl });
2602
- }
2603
- catch (error) {
2604
- this.logger.debug(`Failed to bind MCP context via ${baseUrl}:`, error.message);
2605
- }
2606
- }
1779
+ const binding = await this.mcpContextClient.bind({
1780
+ existing: executionContext.mcpContextId && executionContext.mcpContextBackendUrl ? {
1781
+ mcpContextId: String(executionContext.mcpContextId),
1782
+ backendUrl: String(executionContext.mcpContextBackendUrl),
1783
+ } : null,
1784
+ userId: this.config.get('userId') || this.config.get('email') || 'vigthoria-cli',
1785
+ metadata,
1786
+ data,
1787
+ });
2607
1788
  return {
2608
1789
  ...executionContext,
2609
1790
  localWorkspacePath: localWorkspacePath || executionContext.localWorkspacePath || null,
2610
1791
  targetPath: localWorkspacePath || executionContext.targetPath || executionContext.projectPath || executionContext.workspacePath || null,
2611
1792
  projectPath: localWorkspacePath || executionContext.projectPath || executionContext.targetPath || executionContext.workspacePath || null,
2612
1793
  workspacePath: localWorkspacePath || executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || null,
1794
+ ...(binding ? { mcpContextId: binding.mcpContextId, mcpContextBackendUrl: binding.backendUrl } : {}),
2613
1795
  };
2614
1796
  }
2615
1797
  isServerManagedTempWorkspacePath(pathValue) {
@@ -2721,7 +1903,7 @@ menu {
2721
1903
  files: [],
2722
1904
  };
2723
1905
  const snapshot = this.getAgentWorkspaceSnapshot(rootPath);
2724
- const candidatePaths = snapshot.paths.filter((entry) => !/(^|[\/\\])\.vigthoria([\/\\]|$)/.test(entry));
1906
+ const candidatePaths = snapshot.paths.filter((entry) => !isSensitivePath(entry));
2725
1907
  const focusFiles = this.extractExpectedWorkspaceFiles(requestFocus)
2726
1908
  .map((entry) => this.normalizeWorkspaceRelativePath(entry))
2727
1909
  .filter((entry) => candidatePaths.includes(entry));
@@ -2739,12 +1921,13 @@ menu {
2739
1921
  }
2740
1922
  }
2741
1923
  catch {
2742
- // Missing/unreadable focused HTML remains visible to the server via
2743
- // the original focus list; dependency expansion is best effort.
1924
+ // Best effort: the explicit target remains in focusFiles.
2744
1925
  }
2745
1926
  }
2746
1927
  const hydrationFocusFiles = [...focusFiles, ...linkedFocusAssets];
2747
- const agentStatePaths = this.collectAgentStateSyncPaths(rootPath);
1928
+ // Runtime state remains local. Resume/auth/session material under
1929
+ // .vigthoria is never hydrated into a remote agent workspace.
1930
+ const agentStatePaths = this.collectAgentStateSyncPaths(rootPath).filter((entry) => !isSensitivePath(entry));
2748
1931
  let orderedPaths = [...agentStatePaths, ...hydrationFocusFiles, ...candidatePaths];
2749
1932
  try {
2750
1933
  const ranked = buildSemanticContext(rootPath, requestFocus, 60).topFiles.map((file) => file.path);
@@ -2807,8 +1990,7 @@ menu {
2807
1990
  }
2808
1991
  /**
2809
1992
  * Build complete workspace hydration separately from model context.
2810
- * Remote audits must inspect the same byte-complete assets the user has
2811
- * locally; context compaction must never become filesystem compaction.
1993
+ * Context compaction must not become filesystem compaction.
2812
1994
  */
2813
1995
  buildOutOfBandWorkspaceFiles(context) {
2814
1996
  const resolvedContext = this.ensureExecutionContext(context);
@@ -2826,271 +2008,28 @@ menu {
2826
2008
  * Budget: up to ~2 MB total, per-file cap 200 KB, skip binary extensions.
2827
2009
  */
2828
2010
  collectWorkspaceFileContents(rootPath, filePaths) {
2829
- const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
2830
- const MAX_FILE_BYTES = 200 * 1024;
2831
- const BINARY_EXTENSIONS = new Set([
2832
- '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif',
2833
- '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.flac', '.aac',
2834
- '.zip', '.gz', '.tar', '.rar', '.7z', '.bz2',
2835
- '.exe', '.dll', '.so', '.dylib', '.bin', '.dat',
2836
- '.woff', '.woff2', '.ttf', '.eot', '.otf',
2837
- '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
2838
- '.db', '.sqlite', '.sqlite3',
2839
- '.pyc', '.pyo', '.class', '.o', '.obj',
2840
- '.DS_Store', '.lock',
2841
- ]);
2842
- const result = {};
2843
- let totalBytes = 0;
2844
- for (const relativePath of filePaths) {
2845
- if (totalBytes >= MAX_TOTAL_BYTES)
2846
- break;
2847
- const ext = path.extname(relativePath).toLowerCase();
2848
- if (BINARY_EXTENSIONS.has(ext))
2849
- continue;
2850
- if (/(^|[\/\\])\.(git|hg)([\/\\]|$)/.test(relativePath))
2851
- continue;
2852
- const normalizedPath = relativePath.replace(/\\/g, '/');
2853
- const isAgentStateSync = /^\.vigthoria\/agent-state\//i.test(normalizedPath);
2854
- if (!isAgentStateSync && /(^|[\/\\])\.vigthoria([\/\\]|$)/.test(relativePath))
2855
- continue;
2856
- const absolutePath = path.join(rootPath, relativePath);
2857
- try {
2858
- const stat = fs.statSync(absolutePath);
2859
- if (!stat.isFile() || stat.size > MAX_FILE_BYTES || stat.size === 0)
2860
- continue;
2861
- const content = fs.readFileSync(absolutePath, 'utf8');
2862
- // Skip likely binary content (high ratio of non-printable chars)
2863
- if (/[\x00-\x08\x0e-\x1f]/.test(content.slice(0, 512)))
2864
- continue;
2865
- result[relativePath] = content;
2866
- totalBytes += Buffer.byteLength(content, 'utf8');
2867
- }
2868
- catch {
2869
- // Skip unreadable files
2870
- }
2871
- }
2872
- return result;
2011
+ return this.v3WorkspaceService.collectFileContents(rootPath, filePaths);
2873
2012
  }
2874
2013
  hasAgentWorkspaceOutput(context = {}) {
2875
- try {
2876
- const root = this.resolveAgentTargetPath(context);
2877
- if (!root || !fs.existsSync(root)) {
2878
- return false;
2879
- }
2880
- const stack = [root];
2881
- while (stack.length > 0) {
2882
- const current = stack.pop();
2883
- if (!current)
2884
- continue;
2885
- const entries = fs.readdirSync(current, { withFileTypes: true });
2886
- for (const entry of entries) {
2887
- if (entry.name === '.git' || entry.name === 'node_modules') {
2888
- continue;
2889
- }
2890
- const fullPath = path.join(current, entry.name);
2891
- if (entry.isFile()) {
2892
- return true;
2893
- }
2894
- if (entry.isDirectory()) {
2895
- stack.push(fullPath);
2896
- }
2897
- }
2898
- }
2899
- }
2900
- catch {
2901
- return false;
2902
- }
2903
- return false;
2014
+ return this.v3WorkspaceService.hasOutput(context);
2904
2015
  }
2905
2016
  getAgentWorkspaceSnapshot(rootPath) {
2906
- const stack = [rootPath];
2907
- let fileCount = 0;
2908
- const entries = [];
2909
- while (stack.length > 0) {
2910
- const current = stack.pop();
2911
- if (!current) {
2912
- continue;
2913
- }
2914
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
2915
- if (entry.name === '.git' || entry.name === 'node_modules') {
2916
- continue;
2917
- }
2918
- const fullPath = path.join(current, entry.name);
2919
- if (entry.isDirectory()) {
2920
- stack.push(fullPath);
2921
- continue;
2922
- }
2923
- if (!entry.isFile()) {
2924
- continue;
2925
- }
2926
- fileCount += 1;
2927
- const stat = fs.statSync(fullPath);
2928
- entries.push(`${path.relative(rootPath, fullPath)}:${stat.size}:${stat.mtimeMs}`);
2929
- }
2930
- }
2931
- entries.sort();
2932
- return {
2933
- fileCount,
2934
- paths: entries.map((entry) => entry.split(':', 1)[0]),
2935
- signature: entries.join('|'),
2936
- };
2017
+ return this.v3WorkspaceService.snapshot(rootPath);
2937
2018
  }
2938
2019
  async waitForAgentWorkspaceSettle(context = {}, options = {}) {
2939
- const rootPath = this.resolveAgentTargetPath(context);
2940
- if (!rootPath || !fs.existsSync(rootPath)) {
2941
- return;
2942
- }
2943
- const timeoutMs = options.timeoutMs || 15000;
2944
- const pollMs = options.pollMs || 300;
2945
- const stableMs = options.stableMs || 1200;
2946
- const expectedFiles = Array.isArray(options.expectedFiles)
2947
- ? options.expectedFiles.map((entry) => String(entry || '').trim()).filter(Boolean)
2948
- : [];
2949
- const start = Date.now();
2950
- let stableSince = 0;
2951
- let lastSignature = '';
2952
- while (Date.now() - start < timeoutMs) {
2953
- const snapshot = this.getAgentWorkspaceSnapshot(rootPath);
2954
- if (snapshot.fileCount === 0) {
2955
- stableSince = 0;
2956
- lastSignature = '';
2957
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2958
- continue;
2959
- }
2960
- if (expectedFiles.length > 0 && !expectedFiles.every((filePath) => snapshot.paths.includes(filePath))) {
2961
- stableSince = 0;
2962
- lastSignature = snapshot.signature;
2963
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2964
- continue;
2965
- }
2966
- if (snapshot.signature === lastSignature) {
2967
- if (!stableSince) {
2968
- stableSince = Date.now();
2969
- }
2970
- if (Date.now() - stableSince >= stableMs) {
2971
- return;
2972
- }
2973
- }
2974
- else {
2975
- lastSignature = snapshot.signature;
2976
- stableSince = Date.now();
2977
- }
2978
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2979
- }
2020
+ return this.v3WorkspaceService.waitForSettle(context, options);
2980
2021
  }
2981
2022
  extractExpectedWorkspaceFiles(message = '', context = {}) {
2982
- const candidates = new Set();
2983
- const addMatches = (value) => {
2984
- const text = String(value || '');
2985
- const patterns = [
2986
- /`([^`]+\.(?:html|css|js|jsx|ts|tsx|json|md|py|sh))`/gi,
2987
- /\b([A-Za-z0-9_./-]+\.(?:html|css|js|jsx|ts|tsx|json|md|py|sh))\b/g,
2988
- ];
2989
- for (const pattern of patterns) {
2990
- let match;
2991
- while ((match = pattern.exec(text)) !== null) {
2992
- const filePath = match[1].trim().replace(/^\.\//, '');
2993
- if (filePath && !filePath.startsWith('http://') && !filePath.startsWith('https://')) {
2994
- candidates.add(filePath);
2995
- }
2996
- }
2997
- }
2998
- };
2999
- addMatches(message);
3000
- addMatches(context.rawMessage);
3001
- addMatches(context.agentPrompt);
3002
- return Array.from(candidates);
2023
+ return this.v3WorkspaceService.extractExpectedFiles(message, context);
3003
2024
  }
3004
2025
  captureV3AgentStreamMutation(event, streamedFiles, serverRoot) {
3005
- if (!event || !streamedFiles) {
3006
- return;
3007
- }
3008
- if (event.type === 'file_mutation' && typeof event.path === 'string') {
3009
- const filePath = this.normalizeAgentWorkspaceRelativePath(event.path, serverRoot || undefined);
3010
- if (!filePath) {
3011
- return;
3012
- }
3013
- if (event.action === 'delete') {
3014
- delete streamedFiles[filePath];
3015
- return;
3016
- }
3017
- if (typeof event.content === 'string') {
3018
- streamedFiles[filePath] = event.content;
3019
- }
3020
- return;
3021
- }
3022
- // workspace_snapshot is handled by applyV3AgentStreamEventToWorkspace only —
3023
- // never count hydrated baselines as "files changed".
3024
- // Only count confirmed mutations — not workspace snapshots (hydrated baseline)
3025
- // or tool_call previews that may still fail client-side / read-only guards.
3026
- if (event.type === 'tool_result' && event.success) {
3027
- const name = String(event.name || event.tool || '').trim();
3028
- const args = event.arguments || {};
3029
- const targetPath = typeof event.target === 'string'
3030
- ? event.target
3031
- : (typeof args.path === 'string' ? args.path : '');
3032
- if ((name === 'write_file' || name === 'edit_file') && targetPath) {
3033
- const filePath = this.normalizeAgentWorkspaceRelativePath(targetPath, serverRoot || undefined);
3034
- if (filePath && typeof args.content === 'string') {
3035
- streamedFiles[filePath] = args.content;
3036
- }
3037
- }
3038
- }
2026
+ this.v3WorkspaceService.captureMutation(event, streamedFiles, serverRoot);
3039
2027
  }
3040
2028
  applyV3AgentStreamEventToWorkspace(event, context = {}, serverRoot) {
3041
- if (!event || !['file_mutation', 'workspace_snapshot'].includes(String(event.type || ''))) {
3042
- return false;
3043
- }
3044
- const rootPath = this.resolveAgentTargetPath(context);
3045
- if (!rootPath) {
3046
- return false;
3047
- }
3048
- if (event.type === 'workspace_snapshot' && event.files && typeof event.files === 'object') {
3049
- let applied = false;
3050
- for (const [rawPath, content] of Object.entries(event.files)) {
3051
- if (typeof content !== 'string') {
3052
- continue;
3053
- }
3054
- applied = this.writeV3AgentWorkspaceFile(rootPath, rawPath, content, serverRoot || undefined) || applied;
3055
- }
3056
- return applied;
3057
- }
3058
- if (event.type === 'file_mutation' && typeof event.path === 'string') {
3059
- const relativePath = this.normalizeAgentWorkspaceRelativePath(event.path, serverRoot || undefined);
3060
- if (!relativePath) {
3061
- return false;
3062
- }
3063
- if (event.action === 'delete') {
3064
- return this.deleteV3AgentWorkspaceFile(rootPath, relativePath);
3065
- }
3066
- if (typeof event.content === 'string') {
3067
- return this.writeV3AgentWorkspaceFile(rootPath, relativePath, event.content, rootPath);
3068
- }
3069
- }
3070
- return false;
2029
+ return this.v3WorkspaceService.applyStreamEvent(event, context, serverRoot);
3071
2030
  }
3072
2031
  resolveV3ClientToolPath(rootPath, rawPath, sourceRoots = []) {
3073
- const raw = String(rawPath || '.').trim();
3074
- const roots = [rootPath, ...sourceRoots.filter(Boolean)];
3075
- let relativePath = '';
3076
- for (const sourceRoot of roots) {
3077
- relativePath = this.normalizeAgentWorkspaceRelativePath(raw, sourceRoot);
3078
- if (relativePath || raw === '.' || raw === '') {
3079
- break;
3080
- }
3081
- }
3082
- if (!relativePath && raw !== '.' && raw !== '') {
3083
- const treatAsWorkspaceRoot = roots.some((sourceRoot) => isV3WorkspaceRootAliasPath(raw, sourceRoot));
3084
- if (!treatAsWorkspaceRoot) {
3085
- return null;
3086
- }
3087
- }
3088
- const absolutePath = relativePath ? joinV3WorkspacePath(rootPath, relativePath) : path.resolve(rootPath);
3089
- const resolvedRoot = path.resolve(rootPath);
3090
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3091
- return null;
3092
- }
3093
- return { relativePath: relativePath || '.', absolutePath };
2032
+ return this.v3WorkspaceService.resolveClientToolPath(rootPath, rawPath, sourceRoots);
3094
2033
  }
3095
2034
  recordV3ClientToolMutation(event, context, result) {
3096
2035
  if (!result.success) {
@@ -3136,7 +2075,7 @@ menu {
3136
2075
  const serverRoot = String(context.__v3ServerWorkspaceRoot || '').trim();
3137
2076
  const isRunCommand = name === 'run_command';
3138
2077
  const pathArg = isRunCommand
3139
- ? (args.cwd ?? args.path ?? args.directory ?? '.')
2078
+ ? (args.cwd ?? args.directory ?? '.')
3140
2079
  : (args.path || args.directory || args.cwd || '.');
3141
2080
  let target = this.resolveV3ClientToolPath(rootPath, pathArg, serverRoot ? [serverRoot] : []);
3142
2081
  if (!target && isRunCommand) {
@@ -3170,7 +2109,26 @@ menu {
3170
2109
  return result;
3171
2110
  }
3172
2111
  if (name === 'read_file') {
3173
- return { success: true, output: fs.readFileSync(target.absolutePath, 'utf8') };
2112
+ const content = fs.readFileSync(target.absolutePath, 'utf8');
2113
+ const lines = content.split('\n');
2114
+ const startValue = args.start_line ?? args.startLine ?? args.fromLine ?? args.lineStart;
2115
+ const endValue = args.end_line ?? args.endLine ?? args.toLine ?? args.lineEnd;
2116
+ let startLine = Number.parseInt(String(startValue ?? '1'), 10);
2117
+ let endLine = Number.parseInt(String(endValue ?? '0'), 10);
2118
+ if (!Number.isFinite(startLine) || startLine < 1)
2119
+ startLine = 1;
2120
+ if (startLine > lines.length)
2121
+ startLine = lines.length;
2122
+ if (!Number.isFinite(endLine) || endLine === 0)
2123
+ endLine = lines.length;
2124
+ if (endLine < startLine)
2125
+ endLine = startLine;
2126
+ if (endLine > lines.length)
2127
+ endLine = lines.length;
2128
+ return {
2129
+ success: true,
2130
+ output: lines.slice(startLine - 1, endLine).join('\n'),
2131
+ };
3174
2132
  }
3175
2133
  if (name === 'list_directory') {
3176
2134
  const entries = fs.readdirSync(target.absolutePath, { withFileTypes: true })
@@ -3181,6 +2139,15 @@ menu {
3181
2139
  if (name === 'glob' || name === 'search_files' || name === 'grep') {
3182
2140
  const rawPattern = String(args.pattern || args.query || '').trim();
3183
2141
  const pattern = rawPattern.toLowerCase();
2142
+ let contentPattern = null;
2143
+ if (rawPattern) {
2144
+ try {
2145
+ contentPattern = new RegExp(rawPattern, 'i');
2146
+ }
2147
+ catch {
2148
+ contentPattern = null;
2149
+ }
2150
+ }
3184
2151
  const matches = [];
3185
2152
  const walk = (dir) => {
3186
2153
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -3195,8 +2162,11 @@ menu {
3195
2162
  if (name !== 'glob' && pattern) {
3196
2163
  try {
3197
2164
  const content = fs.readFileSync(absolute, 'utf8');
3198
- if (relative.toLowerCase().includes(pattern.replace(/\*/g, '')) || content.toLowerCase().includes(pattern)) {
3199
- matches.push(`${relative}: ${content.split('\n').find((line) => line.toLowerCase().includes(pattern))?.trim() || '(match)'}`);
2165
+ const lines = content.split('\n');
2166
+ const lineIndex = lines.findIndex((line) => (contentPattern ? contentPattern.test(line) : line.toLowerCase().includes(pattern)));
2167
+ if (relative.toLowerCase().includes(pattern.replace(/\*/g, '')) || lineIndex >= 0) {
2168
+ const displayLine = lineIndex >= 0 ? lineIndex + 1 : 1;
2169
+ matches.push(`${relative}:${displayLine}: ${lineIndex >= 0 ? lines[lineIndex].trim() : '(path match)'}`);
3200
2170
  }
3201
2171
  }
3202
2172
  catch {
@@ -3212,9 +2182,19 @@ menu {
3212
2182
  cwd: searchRoot,
3213
2183
  nodir: true,
3214
2184
  dot: false,
2185
+ follow: false,
3215
2186
  ignore: ['**/node_modules/**', '**/.git/**'],
3216
2187
  })
3217
2188
  .map((entry) => String(entry).replace(/\\/g, '/'))
2189
+ .filter((entry) => {
2190
+ try {
2191
+ resolveWorkspacePath(searchRoot, entry);
2192
+ return true;
2193
+ }
2194
+ catch {
2195
+ return false;
2196
+ }
2197
+ })
3218
2198
  .sort((left, right) => left.localeCompare(right));
3219
2199
  return { success: true, output: globbed.slice(0, 200).join('\n') || '(no matches)' };
3220
2200
  }
@@ -3243,9 +2223,9 @@ menu {
3243
2223
  return { success: true, output: `Syntax check passed: ${target.relativePath}` };
3244
2224
  }
3245
2225
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
3246
- const { execSync } = await import('child_process');
2226
+ const { execFileSync } = await import('child_process');
3247
2227
  try {
3248
- execSync(`node --check "${target.absolutePath}"`, { stdio: 'pipe' });
2228
+ execFileSync(process.execPath, ['--check', target.absolutePath], { stdio: 'pipe', timeout: 10_000, windowsHide: true, env: safeChildProcessEnv() });
3249
2229
  return { success: true, output: `Syntax check passed: ${target.relativePath}` };
3250
2230
  }
3251
2231
  catch (error) {
@@ -3261,36 +2241,73 @@ menu {
3261
2241
  if (!command)
3262
2242
  return { success: false, output: '', error: 'run_command requires command.' };
3263
2243
  try {
3264
- if (process.platform === 'win32') {
3265
- command = this.normalizeWindowsRunCommand(command, serverRoot);
3266
- }
2244
+ command = this.normalizePortableRunCommand(command, serverRoot);
2245
+ }
2246
+ catch (error) {
2247
+ return { success: false, output: '', error: error.message };
2248
+ }
2249
+ let processRequest;
2250
+ try {
2251
+ processRequest = parseSupportedProcess(command);
3267
2252
  }
3268
2253
  catch (error) {
3269
2254
  return { success: false, output: '', error: error.message };
3270
2255
  }
3271
- const cwd = this.resolveRunCommandCwd(rootPath, args, serverRoot);
3272
- const { exec, execFile } = await import('child_process');
2256
+ let cwd = this.resolveRunCommandCwd(rootPath, args, serverRoot);
2257
+ // V3 models sometimes attach a virtual cwd such as /workspace even
2258
+ // when the named validation script is visibly rooted in the bound
2259
+ // client project. Prefer the real project root for an existing,
2260
+ // workspace-relative Node/Python entrypoint so external clients never
2261
+ // depend on a server-only filesystem alias.
2262
+ const processName = path.basename(processRequest.executable).toLowerCase();
2263
+ const rootScript = String(/^(?:node|python|python3)(?:\.exe)?$/.test(processName)
2264
+ ? processRequest.args.find((arg) => !arg.startsWith('-')) || ''
2265
+ : '').replace(/\\/g, '/');
2266
+ if (rootScript && !path.isAbsolute(rootScript) && !rootScript.startsWith('../')) {
2267
+ const rootCandidate = path.resolve(rootPath, rootScript);
2268
+ const normalizedRoot = path.resolve(rootPath);
2269
+ if ((rootCandidate === normalizedRoot || rootCandidate.startsWith(`${normalizedRoot}${path.sep}`))
2270
+ && fs.existsSync(rootCandidate)
2271
+ && fs.statSync(rootCandidate).isFile()) {
2272
+ cwd = normalizedRoot;
2273
+ }
2274
+ }
2275
+ const { execFile } = await import('child_process');
2276
+ // The V3 run_command contract expresses timeout in seconds (default
2277
+ // 30, maximum 300). child_process expects milliseconds. Passing the
2278
+ // model value through unchanged turned a normal timeout=30 request
2279
+ // into 30ms, which reliably killed real validators while tiny unit
2280
+ // commands appeared healthy.
2281
+ const requestedTimeoutSeconds = Number(args.timeout ?? 30);
2282
+ const timeoutSeconds = Number.isFinite(requestedTimeoutSeconds)
2283
+ ? Math.min(300, Math.max(1, requestedTimeoutSeconds))
2284
+ : 30;
2285
+ const timeoutMs = timeoutSeconds * 1000;
3273
2286
  return await new Promise((resolve) => {
3274
- const timeout = Number(args.timeout || 30000);
3275
2287
  const finish = (error, stdout, stderr) => {
3276
2288
  const stdoutText = String(stdout || '');
3277
2289
  const stderrText = String(stderr || '');
3278
2290
  const combined = [stdoutText, stderrText].filter(Boolean).join('\n').slice(0, 12000);
2291
+ const failureDetail = error
2292
+ ? [
2293
+ error.killed ? `Command timed out after ${timeoutSeconds}s` : String(error.message || error),
2294
+ combined,
2295
+ ].filter(Boolean).join('\n').slice(0, 12000)
2296
+ : '';
3279
2297
  resolve({
3280
2298
  success: !error,
3281
2299
  output: combined,
3282
- error: error ? String(error.message || error) : '',
2300
+ error: failureDetail,
3283
2301
  });
3284
2302
  };
3285
- if (process.platform === 'win32') {
3286
- const commandProcessor = process.env.ComSpec
3287
- || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
3288
- execFile(commandProcessor, ['/d', '/s', '/c', command], {
3289
- cwd, timeout, maxBuffer: 1024 * 1024, env: process.env, windowsHide: true,
3290
- }, finish);
3291
- return;
3292
- }
3293
- exec(command, { cwd, timeout, maxBuffer: 1024 * 1024, env: process.env }, finish);
2303
+ execFile(processRequest.executable, processRequest.args, {
2304
+ cwd,
2305
+ timeout: timeoutMs,
2306
+ maxBuffer: 1024 * 1024,
2307
+ env: safeChildProcessEnv(),
2308
+ windowsHide: true,
2309
+ encoding: 'utf8',
2310
+ }, finish);
3294
2311
  });
3295
2312
  }
3296
2313
  return { success: false, output: '', error: `Unsupported client tool: ${name}` };
@@ -3329,84 +2346,13 @@ menu {
3329
2346
  }
3330
2347
  }
3331
2348
  writeV3AgentWorkspaceFile(rootPath, rawPath, content, sourceRoot) {
3332
- const relativePath = this.normalizeAgentWorkspaceRelativePath(rawPath, sourceRoot || rootPath);
3333
- if (!relativePath) {
3334
- return false;
3335
- }
3336
- const absolutePath = joinV3WorkspacePath(rootPath, relativePath);
3337
- const resolvedRoot = path.resolve(rootPath);
3338
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3339
- this.logger.warn(`Refusing to write V3 file outside workspace: ${rawPath}`);
3340
- return false;
3341
- }
3342
- try {
3343
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3344
- if (fs.existsSync(absolutePath)) {
3345
- const existing = fs.readFileSync(absolutePath, 'utf8');
3346
- if (existing === content) {
3347
- return false;
3348
- }
3349
- }
3350
- fs.writeFileSync(absolutePath, content, 'utf8');
3351
- return true;
3352
- }
3353
- catch (error) {
3354
- this.logger.warn(`Failed to write V3 file ${relativePath}: ${error.message}`);
3355
- return false;
3356
- }
3357
- }
3358
- deleteV3AgentWorkspaceFile(rootPath, rawPath) {
3359
- const relativePath = this.normalizeAgentWorkspaceRelativePath(rawPath, rootPath);
3360
- if (!relativePath) {
3361
- return false;
3362
- }
3363
- const absolutePath = joinV3WorkspacePath(rootPath, relativePath);
3364
- const resolvedRoot = path.resolve(rootPath);
3365
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3366
- this.logger.warn(`Refusing to delete V3 file outside workspace: ${rawPath}`);
3367
- return false;
3368
- }
3369
- try {
3370
- if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
3371
- fs.unlinkSync(absolutePath);
3372
- return true;
3373
- }
3374
- }
3375
- catch (error) {
3376
- this.logger.warn(`Failed to delete V3 file ${relativePath}: ${error.message}`);
3377
- }
3378
- return false;
2349
+ return this.v3WorkspaceService.writeFile(rootPath, rawPath, content, sourceRoot);
3379
2350
  }
3380
2351
  recoverAgentWorkspaceFiles(context = {}, streamedFiles = {}, expectedFiles = []) {
3381
- const rootPath = this.resolveAgentTargetPath(context);
3382
- if (!rootPath || Object.keys(streamedFiles).length === 0) {
3383
- return;
3384
- }
3385
- if (!fs.existsSync(rootPath)) {
3386
- try {
3387
- fs.mkdirSync(rootPath, { recursive: true });
3388
- }
3389
- catch {
3390
- return;
3391
- }
3392
- }
3393
- const targets = expectedFiles.length > 0
3394
- ? Array.from(new Set([...expectedFiles, ...Object.keys(streamedFiles)]))
3395
- : Object.keys(streamedFiles);
3396
- for (const targetPath of targets) {
3397
- const relativePath = this.normalizeAgentWorkspaceRelativePath(targetPath, rootPath);
3398
- if (!relativePath) {
3399
- continue;
3400
- }
3401
- const content = streamedFiles[relativePath];
3402
- if (typeof content !== 'string') {
3403
- continue;
3404
- }
3405
- this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath);
3406
- }
2352
+ this.v3WorkspaceService.recover(context, streamedFiles, expectedFiles);
3407
2353
  }
3408
2354
  normalizeAgentWorkspaceRelativePath(rawPath, rootPath) {
3409
- return normalizeV3WorkspaceRelativePath(rawPath, rootPath);
2355
+ return this.v3WorkspaceService.normalizeRelativePath(rawPath, rootPath);
3410
2356
  }
3411
2357
  async ensureAgentFrontendPolish(message = '', context = {}) {
3412
2358
  const rootPath = this.resolveAgentTargetPath(context);
@@ -3541,14 +2487,23 @@ menu {
3541
2487
  if (!candidate || /^https?:\/\//i.test(candidate) || /^data:/i.test(candidate) || candidate.startsWith('#')) {
3542
2488
  return '';
3543
2489
  }
3544
- return candidate.split('?')[0].split('#')[0].replace(/^\//, '');
2490
+ const assetReference = candidate.split('?')[0].split('#')[0];
2491
+ if (/^\/{2,}/.test(assetReference))
2492
+ return '';
2493
+ const relative = assetReference.replace(/^\//, '');
2494
+ try {
2495
+ return assertSafeRelativePath(relative);
2496
+ }
2497
+ catch {
2498
+ return '';
2499
+ }
3545
2500
  };
3546
2501
  const ensureTextAsset = (relativePath, content) => {
3547
2502
  const normalized = localAssetPath(relativePath);
3548
2503
  if (!normalized) {
3549
2504
  return '';
3550
2505
  }
3551
- const absolutePath = path.join(rootPath, normalized);
2506
+ const absolutePath = resolveWorkspacePath(rootPath, normalized, { allowMissing: true });
3552
2507
  if (!fs.existsSync(absolutePath)) {
3553
2508
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3554
2509
  fs.writeFileSync(absolutePath, content, 'utf8');
@@ -3561,8 +2516,8 @@ menu {
3561
2516
  const jsRefs = Array.from(html.matchAll(/<script[^>]+src=["']([^"']+\.(?:js|mjs)(?:\?[^"']*)?)["']/gi))
3562
2517
  .map((match) => localAssetPath(match[1]))
3563
2518
  .filter(Boolean);
3564
- let cssPath = cssRefs.map((ref) => path.join(rootPath, ref)).find((candidate) => fs.existsSync(candidate)) || '';
3565
- let jsPath = jsRefs.map((ref) => path.join(rootPath, ref)).find((candidate) => fs.existsSync(candidate)) || '';
2519
+ let cssPath = cssRefs.map((ref) => resolveWorkspacePath(rootPath, ref, { allowMissing: true })).find((candidate) => fs.existsSync(candidate)) || '';
2520
+ let jsPath = jsRefs.map((ref) => resolveWorkspacePath(rootPath, ref, { allowMissing: true })).find((candidate) => fs.existsSync(candidate)) || '';
3566
2521
  const cssTemplate = this.buildFallbackFrontendCss(surfaceLabel);
3567
2522
  const jsTemplate = this.buildFallbackFrontendJs(surfaceLabel);
3568
2523
  if (!cssPath && cssRefs.length > 0) {
@@ -3740,8 +2695,18 @@ document.addEventListener('DOMContentLoaded', () => {
3740
2695
  if (!candidate || /^https?:\/\//i.test(candidate) || /^data:/i.test(candidate) || candidate.startsWith('#')) {
3741
2696
  return true;
3742
2697
  }
3743
- const sanitized = candidate.split('?')[0].split('#')[0].replace(/^\//, '');
3744
- return !sanitized || fs.existsSync(path.join(rootPath, sanitized));
2698
+ const assetReference = candidate.split('?')[0].split('#')[0];
2699
+ if (/^\/{2,}/.test(assetReference))
2700
+ return false;
2701
+ const sanitized = assetReference.replace(/^\//, '');
2702
+ if (!sanitized)
2703
+ return true;
2704
+ try {
2705
+ return fs.existsSync(resolveWorkspacePath(rootPath, sanitized, { allowMissing: true }));
2706
+ }
2707
+ catch {
2708
+ return false;
2709
+ }
3745
2710
  };
3746
2711
  const repairedHtml = html.replace(/(<img\b[^>]*\ssrc=["'])([^"']+)(["'][^>]*>)/gi, (match, prefix, assetPath, suffix) => {
3747
2712
  if (hasLocalAsset(assetPath)) {
@@ -3752,7 +2717,7 @@ document.addEventListener('DOMContentLoaded', () => {
3752
2717
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800"><defs><linearGradient id="heroGradient" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0f172a"/><stop offset="1" stop-color="#2563eb"/></linearGradient></defs><rect width="1200" height="800" rx="40" ry="40" fill="url(#heroGradient)"/><circle cx="220" cy="210" r="130" fill="rgba(255,255,255,0.08)"/><circle cx="980" cy="600" r="170" fill="rgba(255,255,255,0.12)"/><text x="84" y="700" fill="#ffffff" font-family="Arial, sans-serif" font-size="64" font-weight="700">${safeLabel}</text></svg>`;
3753
2718
  return `${prefix}data:image/svg+xml;utf8,${encodeURIComponent(svg)}${suffix}`;
3754
2719
  });
3755
- const repairedCss = css.replace(/url\((["']?)([^)"']+)\1\)/gi, (match, quote, assetPath) => {
2720
+ const repairedCss = css.replace(/url\((["']?)([^)"']+)\1\)/gi, (match, _quote, assetPath) => {
3756
2721
  if (hasLocalAsset(assetPath)) {
3757
2722
  return match;
3758
2723
  }
@@ -3939,12 +2904,34 @@ document.addEventListener('DOMContentLoaded', () => {
3939
2904
  : {};
3940
2905
  return { ...streamed, ...clientMutations };
3941
2906
  }
3942
- finalizeV3AgentWorkflowResponse(data, response) {
3943
- const changedFiles = this.resolveAgentChangedFiles(data);
2907
+ finalizeV3AgentWorkflowResponse(data, response, journal = null) {
2908
+ let changedFiles = this.resolveAgentChangedFiles(data);
2909
+ const previewGate = response.metadata?.previewGate;
2910
+ let mutation;
2911
+ if (journal) {
2912
+ const proofSucceeded = previewGate?.required === true
2913
+ ? previewGate.passed === true && previewGate.skipped !== true
2914
+ : true;
2915
+ mutation = finalizeMutationTransaction(journal, proofSucceeded);
2916
+ if (!proofSucceeded)
2917
+ changedFiles = {};
2918
+ }
3944
2919
  this.activeV3StreamedFiles = null;
3945
- this.activeV3StreamContext = null;
3946
2920
  return {
3947
2921
  ...response,
2922
+ metadata: {
2923
+ ...(response.metadata || {}),
2924
+ ...(journal ? { operationId: journal.operationId } : {}),
2925
+ ...(mutation ? { mutation, partialMutation: mutation.partialMutation } : {}),
2926
+ ...(previewGate && mutation ? {
2927
+ previewGate: {
2928
+ ...previewGate,
2929
+ operationId: journal?.operationId,
2930
+ mutation,
2931
+ partialMutation: mutation.partialMutation,
2932
+ },
2933
+ } : {}),
2934
+ },
3948
2935
  changedFiles,
3949
2936
  };
3950
2937
  }
@@ -3963,7 +2950,6 @@ document.addEventListener('DOMContentLoaded', () => {
3963
2950
  let serverWorkspaceRoot = null;
3964
2951
  const streamedFiles = {};
3965
2952
  this.activeV3StreamedFiles = streamedFiles;
3966
- this.activeV3StreamContext = context;
3967
2953
  context.__v3StreamedFiles = streamedFiles;
3968
2954
  const idleTimeoutMs = (() => {
3969
2955
  const base = context.agentIdleTimeoutMs ?? DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS;
@@ -4224,6 +3210,13 @@ document.addEventListener('DOMContentLoaded', () => {
4224
3210
  }
4225
3211
  async runV3AgentWorkflow(message, context = {}) {
4226
3212
  const executionContext = await this.bindExecutionContext(context);
3213
+ const mutationRoot = this.resolveAgentTargetPath(executionContext);
3214
+ let mutationJournal = null;
3215
+ if (mutationRoot && fs.existsSync(mutationRoot) && fs.statSync(mutationRoot).isDirectory()) {
3216
+ mutationJournal = new MutationJournal(mutationRoot, executionContext.operationId);
3217
+ executionContext.operationId = mutationJournal.operationId;
3218
+ executionContext.__mutationJournal = mutationJournal;
3219
+ }
4227
3220
  const requestedTimeoutMs = Number(executionContext.agentTimeoutMs ?? DEFAULT_V3_AGENT_TIMEOUT_MS);
4228
3221
  const baseTimeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0 ? requestedTimeoutMs : 0;
4229
3222
  const expectedFiles = this.extractExpectedWorkspaceFiles(message, executionContext);
@@ -4263,11 +3256,12 @@ document.addEventListener('DOMContentLoaded', () => {
4263
3256
  workspace_files: useRelaxedAttempt ? undefined : workspaceFiles,
4264
3257
  context_id: contextIdOverride ?? requestExecutionContext.contextId,
4265
3258
  mcp_context_id: useRelaxedAttempt ? null : requestExecutionContext.mcpContextId || null,
3259
+ operation_id: executionContext.operationId || undefined,
4266
3260
  stream: true,
4267
3261
  });
4268
3262
  for (const baseUrl of this.getV3AgentBaseUrls(preferLocalV3)) {
4269
3263
  let contextIdOverride;
4270
- const authPreflight = await this.runV3AgentAuthPreflight(baseUrl, buildRequestBody(), requestExecutionContext);
3264
+ const authPreflight = await this.runV3AgentAuthPreflight(baseUrl, requestExecutionContext);
4271
3265
  if (!authPreflight.ok) {
4272
3266
  errors.push(`V3 auth preflight ${authPreflight.status} [${authPreflight.endpoint}]: ${authPreflight.reason || 'Unauthorized'}`);
4273
3267
  continue;
@@ -4318,10 +3312,14 @@ document.addEventListener('DOMContentLoaded', () => {
4318
3312
  const continueTimeoutId = timeoutMs > 0 ? setTimeout(() => continueController.abort(), timeoutMs) : null;
4319
3313
  try {
4320
3314
  const continueHeaders = await this.getV3AgentHeaders();
4321
- const continueResponse = await fetch(this.getV3AgentContinueUrl(baseUrl), {
3315
+ if (executionContext.operationId) {
3316
+ continueHeaders['Idempotency-Key'] = String(executionContext.operationId);
3317
+ continueHeaders['X-Vigthoria-Operation-Id'] = String(executionContext.operationId);
3318
+ }
3319
+ const continueResponse = await this.fetch(this.getV3AgentContinueUrl(baseUrl), {
4322
3320
  method: 'POST',
4323
3321
  headers: { ...continueHeaders, 'Content-Type': 'application/json' },
4324
- body: JSON.stringify(continueBody),
3322
+ body: JSON.stringify(scanOutboundContext(continueBody).value),
4325
3323
  signal: continueController.signal,
4326
3324
  });
4327
3325
  if (!continueResponse.ok) {
@@ -4350,17 +3348,17 @@ document.addEventListener('DOMContentLoaded', () => {
4350
3348
  backendUrl: baseUrl,
4351
3349
  partial: continuationData.checkpointed === true,
4352
3350
  metadata: { source: 'v3-agent', mode: 'agent', contextId: finalContextId, continuations, previewGate },
4353
- });
3351
+ }, mutationJournal);
4354
3352
  }
4355
3353
  const contextId = data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
4356
3354
  const mcpContextId = response.headers.get('x-mcp-context-id') || requestExecutionContext.mcpContextId || null;
4357
3355
  this.recoverAgentWorkspaceFiles(executionContext, data.files || {}, expectedFiles);
4358
3356
  if (data.terminal_error) {
4359
3357
  // A pre-existing local project is not proof that this run produced
4360
- // valid output. Preserve real streamed mutations, but never run or
4361
- // report a preview gate after a terminal planner/executor error.
3358
+ // valid output. A terminal planner/executor error fails proof and
3359
+ // causes the operation journal to roll back streamed mutations.
4362
3360
  const failedPreviewGate = {
4363
- required: false,
3361
+ required: true,
4364
3362
  passed: false,
4365
3363
  skipped: true,
4366
3364
  error: 'Agent execution failed before preview validation.',
@@ -4379,22 +3377,18 @@ document.addEventListener('DOMContentLoaded', () => {
4379
3377
  previewGate: failedPreviewGate,
4380
3378
  workflowError: data.terminal_error,
4381
3379
  },
4382
- });
3380
+ }, mutationJournal);
4383
3381
  }
4384
3382
  await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
4385
3383
  await this.ensureAgentFrontendPolish(message, executionContext);
4386
3384
  const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4387
- if (previewGate.required && previewGate.passed !== true && !this.hasAgentWorkspaceOutput(executionContext)) {
4388
- errors.push(`${baseUrl}: workflow rejected the result - ${previewGate.error || 'Workspace changes were not fully validated.'}`);
4389
- continue;
4390
- }
4391
3385
  return this.finalizeV3AgentWorkflowResponse(data, {
4392
3386
  content: this.formatV3AgentResponse(data),
4393
3387
  taskId: data.task_id || null,
4394
3388
  contextId,
4395
3389
  backendUrl: baseUrl,
4396
3390
  metadata: { source: 'v3-agent', mode: 'agent', contextId, mcpContextId, previewGate },
4397
- });
3391
+ }, mutationJournal);
4398
3392
  }
4399
3393
  catch (error) {
4400
3394
  if (error && error.name === 'AbortError' && error.partialData && this.hasAgentWorkspaceOutput(executionContext)) {
@@ -4409,7 +3403,17 @@ document.addEventListener('DOMContentLoaded', () => {
4409
3403
  backendUrl: baseUrl,
4410
3404
  partial: true,
4411
3405
  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 },
4412
- });
3406
+ }, mutationJournal);
3407
+ }
3408
+ if (mutationJournal?.hasMutations()) {
3409
+ const mutation = mutationJournal.rollback();
3410
+ mutationJournal = null;
3411
+ this.activeV3StreamedFiles = null;
3412
+ const ambiguousError = new Error(`V3 mutation outcome is ambiguous for operation ${mutation.operationId}; workspace rollback was attempted and no alternate backend retry was allowed.`);
3413
+ ambiguousError.operationId = mutation.operationId;
3414
+ ambiguousError.mutation = mutation;
3415
+ ambiguousError.partialMutation = mutation.partialMutation;
3416
+ throw ambiguousError;
4413
3417
  }
4414
3418
  errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4415
3419
  }
@@ -4437,7 +3441,7 @@ document.addEventListener('DOMContentLoaded', () => {
4437
3441
  if (onlyUnauthorizedErrors && usingStoredConfigToken) {
4438
3442
  const gatewayTokenCheck = await this.validateToken({ allowNetworkFailOpen: true, enforceTokenShape: true });
4439
3443
  if (!gatewayTokenCheck.valid) {
4440
- this.config.clearAuth();
3444
+ this.authSession.clearLocalCredentials();
4441
3445
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
4442
3446
  }
4443
3447
  const rejectedEndpoints = Array.from(new Set(errors
@@ -4459,28 +3463,30 @@ document.addEventListener('DOMContentLoaded', () => {
4459
3463
  await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles: ['index.html', 'styles.css', 'scripts.js'] });
4460
3464
  await this.ensureAgentFrontendPolish(message, executionContext);
4461
3465
  const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4462
- return {
3466
+ return this.finalizeV3AgentWorkflowResponse({}, {
4463
3467
  content: `Recovered a local SaaS workspace scaffold for ${appName} after repeated V3 materialization failures.`,
4464
3468
  taskId: null,
4465
3469
  contextId: executionContext.contextId || null,
4466
3470
  backendUrl: 'local-emergency-scaffold',
4467
3471
  partial: true,
4468
3472
  metadata: { source: 'v3-agent-emergency-scaffold', mode: 'agent', previewGate, emergencyScaffold: true },
4469
- };
3473
+ }, mutationJournal);
4470
3474
  }
4471
3475
  }
4472
- throw new Error(errors.join(' | '));
3476
+ const finalError = new Error(errors.join(' | '));
3477
+ if (mutationJournal) {
3478
+ const mutation = mutationJournal.rollback();
3479
+ mutationJournal = null;
3480
+ finalError.operationId = mutation.operationId;
3481
+ finalError.mutation = mutation;
3482
+ finalError.partialMutation = mutation.partialMutation;
3483
+ }
3484
+ throw finalError;
4473
3485
  }
4474
3486
  async startV3BackgroundJob(message, context = {}) {
4475
3487
  await this.ensureV3ServiceKey();
4476
3488
  const executionContext = await this.bindExecutionContext({
4477
3489
  ...context,
4478
- // Background callers historically supplied only workspace metadata.
4479
- // Without the request in the execution context, buildV3AgentContext()
4480
- // classified every detached job from an empty prompt and emitted an
4481
- // `analysis_only` workflow. V3 would then inspect the workspace but was
4482
- // correctly forbidden from writing. Preserve an explicit caller value,
4483
- // otherwise make the submitted job request authoritative for routing.
4484
3490
  rawPrompt: context.rawPrompt || context.prompt || message,
4485
3491
  prompt: context.prompt || context.rawPrompt || message,
4486
3492
  backgroundJob: true,
@@ -4500,1667 +3506,180 @@ document.addEventListener('DOMContentLoaded', () => {
4500
3506
  workspace_files: workspaceFiles,
4501
3507
  stream: false,
4502
3508
  model: resolvedModel,
4503
- mcp_context_id: executionContext.mcpContextId || undefined,
4504
- };
4505
- const errors = [];
4506
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4507
- try {
4508
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl), {
4509
- method: 'POST',
4510
- headers: await this.getV3AgentHeaders(),
4511
- body: JSON.stringify(body),
4512
- });
4513
- if (!response.ok) {
4514
- errors.push(`${baseUrl}: ${response.status} ${await response.text().catch(() => '')}`);
4515
- continue;
4516
- }
4517
- return await response.json();
4518
- }
4519
- catch (error) {
4520
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4521
- }
4522
- }
4523
- throw new Error(`Unable to start background job: ${errors.join('; ')}`);
4524
- }
4525
- async listV3BackgroundJobs(limit = 20) {
4526
- await this.ensureV3ServiceKey();
4527
- const errors = [];
4528
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4529
- try {
4530
- const response = await fetch(`${this.getV3AgentBackgroundUrl(baseUrl)}?limit=${encodeURIComponent(String(limit))}`, {
4531
- method: 'GET',
4532
- headers: await this.getV3AgentHeaders(),
4533
- });
4534
- if (!response.ok) {
4535
- errors.push(`${baseUrl}: ${response.status}`);
4536
- continue;
4537
- }
4538
- const data = await response.json();
4539
- return Array.isArray(data.jobs) ? data.jobs : [];
4540
- }
4541
- catch (error) {
4542
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4543
- }
4544
- }
4545
- throw new Error(`Unable to list background jobs: ${errors.join('; ')}`);
4546
- }
4547
- async getV3BackgroundJob(jobId) {
4548
- await this.ensureV3ServiceKey();
4549
- const errors = [];
4550
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4551
- try {
4552
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, encodeURIComponent(jobId)), {
4553
- method: 'GET',
4554
- headers: await this.getV3AgentHeaders(),
4555
- });
4556
- if (!response.ok) {
4557
- errors.push(`${baseUrl}: ${response.status}`);
4558
- continue;
4559
- }
4560
- return await response.json();
4561
- }
4562
- catch (error) {
4563
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4564
- }
4565
- }
4566
- throw new Error(`Unable to get background job: ${errors.join('; ')}`);
4567
- }
4568
- async cancelV3BackgroundJob(jobId) {
4569
- await this.ensureV3ServiceKey();
4570
- const errors = [];
4571
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4572
- try {
4573
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, `${encodeURIComponent(jobId)}/cancel`), {
4574
- method: 'POST',
4575
- headers: await this.getV3AgentHeaders(),
4576
- });
4577
- if (!response.ok) {
4578
- errors.push(`${baseUrl}: ${response.status}`);
4579
- continue;
4580
- }
4581
- return await response.json();
4582
- }
4583
- catch (error) {
4584
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4585
- }
4586
- }
4587
- throw new Error(`Unable to cancel background job: ${errors.join('; ')}`);
4588
- }
4589
- async resolveV3BackgroundApproval(jobId, approvalId, approved, allowMode = 'once') {
4590
- await this.ensureV3ServiceKey();
4591
- const errors = [];
4592
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4593
- try {
4594
- const response = await fetch(this.getV3AgentApprovalUrl(baseUrl), {
4595
- method: 'POST',
4596
- headers: await this.getV3AgentHeaders(),
4597
- body: JSON.stringify({
4598
- context_id: jobId,
4599
- approval_id: approvalId,
4600
- approved,
4601
- allow_mode: allowMode,
4602
- }),
4603
- });
4604
- if (!response.ok) {
4605
- errors.push(`${baseUrl}: ${response.status}`);
4606
- continue;
4607
- }
4608
- return await response.json();
4609
- }
4610
- catch (error) {
4611
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4612
- }
4613
- }
4614
- throw new Error(`Unable to resolve background approval: ${errors.join('; ')}`);
4615
- }
4616
- async getV3BackgroundJobFiles(jobId) {
4617
- await this.ensureV3ServiceKey();
4618
- const errors = [];
4619
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4620
- try {
4621
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, `${encodeURIComponent(jobId)}/files`), {
4622
- method: 'GET',
4623
- headers: await this.getV3AgentHeaders(),
4624
- });
4625
- if (!response.ok) {
4626
- errors.push(`${baseUrl}: ${response.status}`);
4627
- continue;
4628
- }
4629
- return await response.json();
4630
- }
4631
- catch (error) {
4632
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4633
- }
4634
- }
4635
- throw new Error(`Unable to fetch background job files: ${errors.join('; ')}`);
4636
- }
4637
- async applyV3BackgroundJobFiles(jobId, context = {}) {
4638
- const [data, job] = await Promise.all([
4639
- this.getV3BackgroundJobFiles(jobId),
4640
- this.getV3BackgroundJob(jobId),
4641
- ]);
4642
- if (job?.status !== 'completed' || job?.result?.success === false || job?.result?.type === 'error') {
4643
- throw new Error(`Background job ${jobId} is not a successful completed job and cannot be applied.`);
4644
- }
4645
- const mutatedPaths = new Set();
4646
- const addMutatedPath = (value) => {
4647
- const normalized = this.normalizeWorkspaceRelativePath(String(value || ''));
4648
- if (!normalized || path.isAbsolute(normalized) || normalized.split('/').includes('..'))
4649
- return;
4650
- mutatedPaths.add(normalized);
4651
- };
4652
- for (const filePath of data.confirmed_mutations || []) {
4653
- addMutatedPath(filePath);
4654
- }
4655
- for (const event of Array.isArray(job?.events) ? job.events : []) {
4656
- if (event?.type === 'file_mutation' && event?.action !== 'delete') {
4657
- addMutatedPath(event.path);
4658
- }
4659
- if (event?.type === 'executor_complete') {
4660
- let summary = event.summary;
4661
- if (typeof summary === 'string') {
4662
- try {
4663
- summary = JSON.parse(summary);
4664
- }
4665
- catch {
4666
- summary = null;
4667
- }
4668
- }
4669
- if (summary?.status === 'completed') {
4670
- for (const filePath of [...(summary.changed_files || []), ...(summary.created_files || [])]) {
4671
- addMutatedPath(filePath);
4672
- }
4673
- }
4674
- }
4675
- }
4676
- if (mutatedPaths.size === 0) {
4677
- throw new Error(`Background job ${jobId} has no confirmed successful file mutations to apply.`);
4678
- }
4679
- const rootPath = this.resolveAgentTargetPath({
4680
- ...context,
4681
- workspacePath: context.workspacePath || data.local_workspace_path,
4682
- projectPath: context.projectPath || data.local_workspace_path,
4683
- targetPath: context.targetPath || data.local_workspace_path,
4684
- });
4685
- if (!rootPath) {
4686
- throw new Error('No local workspace path available for applying background job files.');
4687
- }
4688
- const applied = [];
4689
- const skipped = [];
4690
- for (const [relativePath, content] of Object.entries(data.files || {})) {
4691
- const normalizedRelativePath = this.normalizeWorkspaceRelativePath(relativePath);
4692
- if (!mutatedPaths.has(normalizedRelativePath)) {
4693
- skipped.push(relativePath);
4694
- continue;
4695
- }
4696
- if (typeof content !== 'string') {
4697
- skipped.push(relativePath);
4698
- continue;
4699
- }
4700
- if (this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath)) {
4701
- applied.push(relativePath);
4702
- }
4703
- else {
4704
- skipped.push(relativePath);
4705
- }
4706
- }
4707
- return { applied, skipped, status: data.status };
4708
- }
4709
- formatOperatorResponse(data = {}) {
4710
- // If the server returned a direct answer field, prefer it (for lookup tasks)
4711
- if (typeof data.answer === 'string' && data.answer.trim()) {
4712
- return data.answer.trim();
4713
- }
4714
- if (typeof data.result === 'string' && data.result.trim()) {
4715
- return data.result.trim();
4716
- }
4717
- const lines = [];
4718
- if (data.summary) {
4719
- lines.push(String(data.summary).trim());
4720
- }
4721
- if (Array.isArray(data.completed_agents) && data.completed_agents.length > 0) {
4722
- lines.push(`Completed agents: ${data.completed_agents.join(', ')}`);
4723
- }
4724
- if (data.details) {
4725
- const detailText = JSON.stringify(data.details, null, 2);
4726
- if (detailText && detailText !== '{}') {
4727
- lines.push(detailText.length > 12000 ? `${detailText.slice(0, 12000)}\n\n[Operator output truncated]` : detailText);
4728
- }
4729
- }
4730
- return lines.filter(Boolean).join('\n\n').trim() || 'Operator workflow completed.';
4731
- }
4732
- async runOperatorWorkflow(message, context = {}) {
4733
- const executionContext = await this.bindExecutionContext(context);
4734
- const requestedOperatorTimeoutMs = Number(context.operatorTimeoutMs ?? DEFAULT_OPERATOR_TIMEOUT_MS);
4735
- const timeoutMs = Number.isFinite(requestedOperatorTimeoutMs) && requestedOperatorTimeoutMs > 0
4736
- ? requestedOperatorTimeoutMs
4737
- : 0;
4738
- const errors = [];
4739
- const authToken = this.config.get('authToken');
4740
- // Collect a lightweight workspace file listing so the operator can
4741
- // ground its analysis in real files rather than returning files_analyzed: 0.
4742
- const workspacePath = executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || process.cwd();
4743
- const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
4744
- const requestedModel = String(executionContext.model || 'code');
4745
- const resolvedModel = this.resolveModelId(requestedModel);
4746
- for (const baseUrl of this.getOperatorBaseUrls()) {
4747
- const controller = new AbortController();
4748
- const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
4749
- try {
4750
- const response = await fetch(this.getOperatorStreamUrl(baseUrl), {
4751
- method: 'POST',
4752
- headers: {
4753
- 'Content-Type': 'application/json',
4754
- Accept: 'text/event-stream',
4755
- ...(executionContext.mcpContextId ? { 'X-MCP-Context-Id': String(executionContext.mcpContextId) } : {}),
4756
- ...(authToken ? {
4757
- Authorization: `Bearer ${authToken}`,
4758
- Cookie: `vigthoria-auth-token=${authToken}`,
4759
- } : {}),
4760
- },
4761
- body: JSON.stringify({
4762
- prompt: message,
4763
- task: message,
4764
- raw_prompt: executionContext.rawPrompt || null,
4765
- model: requestedModel,
4766
- preferred_model: resolvedModel,
4767
- context_id: executionContext.contextId,
4768
- trace_id: executionContext.traceId,
4769
- mcp_context_id: executionContext.mcpContextId || null,
4770
- context: {
4771
- workspace: { path: workspacePath },
4772
- workspace_path: workspacePath,
4773
- workspace_summary: workspaceSummary,
4774
- model: resolvedModel,
4775
- history: executionContext.history || [],
4776
- executionSurface: executionContext.executionSurface || 'cli',
4777
- clientSurface: executionContext.clientSurface || 'cli',
4778
- contextId: executionContext.contextId,
4779
- traceId: executionContext.traceId,
4780
- mcpContextId: executionContext.mcpContextId || null,
4781
- mcp_context_id: executionContext.mcpContextId || null,
4782
- rawPrompt: executionContext.rawPrompt || null,
4783
- requestStartedAt: executionContext.requestStartedAt,
4784
- vigthoriaBrain: executionContext.vigthoriaBrain || null,
4785
- vigthoria_brain: executionContext.vigthoriaBrain || null,
4786
- },
4787
- workflow_type: executionContext.workflowType || 'full',
4788
- options: {
4789
- stream: true,
4790
- save_to_vigflow: executionContext.savePlanToVigFlow === true,
4791
- },
4792
- }),
4793
- signal: controller.signal,
4794
- });
4795
- if (!response.ok) {
4796
- const errorText = await response.text().catch(() => '');
4797
- throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
4798
- }
4799
- if (!response.body || typeof response.body.getReader !== 'function') {
4800
- const fallbackData = await response.json();
4801
- return {
4802
- content: this.formatOperatorResponse(fallbackData),
4803
- workflowId: fallbackData.workflow_id || null,
4804
- contextId: fallbackData.context_id || response.headers.get('x-context-id') || executionContext.contextId || null,
4805
- backendUrl: baseUrl,
4806
- savedWorkflow: fallbackData.saved_workflow || null,
4807
- metadata: {
4808
- source: 'operator',
4809
- mode: 'operator',
4810
- contextId: fallbackData.context_id || response.headers.get('x-context-id') || executionContext.contextId || null,
4811
- mcpContextId: fallbackData.mcp_context_id || response.headers.get('x-mcp-context-id') || executionContext.mcpContextId || null,
4812
- },
4813
- };
4814
- }
4815
- const reader = response.body.getReader();
4816
- const decoder = new TextDecoder();
4817
- let buffer = '';
4818
- let workflowId = null;
4819
- let contextId = response.headers.get('x-context-id') || executionContext.contextId || null;
4820
- let mcpContextId = response.headers.get('x-mcp-context-id') || executionContext.mcpContextId || null;
4821
- let resultPayload = null;
4822
- let savedWorkflow = null;
4823
- while (true) {
4824
- const { done, value } = await reader.read();
4825
- if (done) {
4826
- break;
4827
- }
4828
- buffer += decoder.decode(value, { stream: true });
4829
- const chunks = buffer.split('\n\n');
4830
- buffer = chunks.pop() || '';
4831
- for (const chunk of chunks) {
4832
- const lines = chunk.split('\n');
4833
- let eventName = 'message';
4834
- const dataLines = [];
4835
- for (const line of lines) {
4836
- if (line.startsWith('event:')) {
4837
- eventName = line.slice(6).trim();
4838
- }
4839
- else if (line.startsWith('data:')) {
4840
- dataLines.push(line.slice(5).trim());
4841
- }
4842
- }
4843
- if (dataLines.length === 0) {
4844
- continue;
4845
- }
4846
- let payload = null;
4847
- try {
4848
- payload = JSON.parse(dataLines.join('\n'));
4849
- }
4850
- catch {
4851
- payload = { raw: dataLines.join('\n') };
4852
- }
4853
- if (payload.workflow_id && !workflowId) {
4854
- workflowId = payload.workflow_id;
4855
- }
4856
- if (!contextId && typeof payload.context_id === 'string' && payload.context_id.trim()) {
4857
- contextId = payload.context_id.trim();
4858
- }
4859
- if (!mcpContextId && typeof payload.mcp_context_id === 'string' && payload.mcp_context_id.trim()) {
4860
- mcpContextId = payload.mcp_context_id.trim();
4861
- }
4862
- if (!savedWorkflow && payload.saved_workflow && typeof payload.saved_workflow === 'object') {
4863
- savedWorkflow = payload.saved_workflow;
4864
- }
4865
- if (typeof context.onStreamEvent === 'function') {
4866
- try {
4867
- context.onStreamEvent({ type: eventName, ...payload, context_id: payload.context_id || contextId, mcp_context_id: payload.mcp_context_id || mcpContextId });
4868
- }
4869
- catch {
4870
- // UI callback failures must not break operator execution.
4871
- }
4872
- }
4873
- if (eventName === 'result') {
4874
- resultPayload = payload;
4875
- }
4876
- if (eventName === 'error') {
4877
- throw new Error(payload.error || payload.message || 'Operator workflow failed');
4878
- }
4879
- }
4880
- }
4881
- return {
4882
- content: this.formatOperatorResponse(resultPayload || {}),
4883
- workflowId,
4884
- contextId,
4885
- backendUrl: baseUrl,
4886
- savedWorkflow,
4887
- metadata: { source: 'operator', mode: 'operator', contextId, mcpContextId, savedWorkflowId: savedWorkflow?.id || null },
4888
- };
4889
- }
4890
- catch (error) {
4891
- const isAbort = error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
4892
- if (isAbort) {
4893
- const mins = Math.round(timeoutMs / 60000);
4894
- throw new CLIError(`Operator workflow timed out after ${mins} minute(s). You can increase the timeout with VIGTHORIA_OPERATOR_TIMEOUT_MS.`, 'timeout', error);
4895
- }
4896
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4897
- }
4898
- finally {
4899
- if (timeoutId)
4900
- clearTimeout(timeoutId);
4901
- }
4902
- }
4903
- throw new CLIError(`Operator workflow failed on all endpoints: ${errors.join(' | ')}`, 'model_backend');
4904
- }
4905
- /**
4906
- * Chat API - Direct Vigthoria Models API Architecture
4907
- *
4908
- * PRIMARY: api.vigthoria.io - Direct access to Vigthoria Model Router
4909
- * This is the centralized public API for all AI operations.
4910
- * Works for both authenticated and unauthenticated users.
4911
- *
4912
- * FALLBACK: Coder Cloud API - For authenticated users only
4913
- *
4914
- * NO localhost fallbacks - CLI is for external users, not server-side!
4915
- */
4916
- async chat(messages, model, useLocal = false) {
4917
- this.lastChatTransportErrors = [];
4918
- const resolvedModel = this.resolveModelId(model);
4919
- const candidateModels = this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()
4920
- ? [this.getSelfHostedFallbackModelId(resolvedModel, model)]
4921
- : [resolvedModel];
4922
- const fallbackModel = this.getFallbackModelId(resolvedModel);
4923
- if (fallbackModel && fallbackModel !== resolvedModel) {
4924
- candidateModels.push(fallbackModel);
4925
- }
4926
- for (const candidateModel of candidateModels) {
4927
- const response = await this.tryChatWithModel(messages, candidateModel, model);
4928
- if (response) {
4929
- if (candidateModel !== resolvedModel) {
4930
- this.logger.debug(`Recovered chat request using fallback model: ${candidateModel}`);
4931
- }
4932
- return response;
4933
- }
4934
- }
4935
- // Keep route diagnostics internal. Users only need to know the Vigthoria service is temporarily unavailable.
4936
- if (this.lastChatTransportErrors.length > 0) {
4937
- this.logger.debug(`Chat transport failures: ${this.lastChatTransportErrors.slice(0, 4).join(' | ')}`);
4938
- }
4939
- throw new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend');
4940
- }
4941
- shouldSkipCloudRoutes(resolvedModel) {
4942
- return this.shouldSimulateCloudFailure() && this.isCloudModelId(resolvedModel);
4943
- }
4944
- async tryChatWithModel(messages, resolvedModel, requestedModel) {
4945
- const routeFailures = [];
4946
- const preferSelfHostedFirst = this.isSelfHostedPreferredModel(resolvedModel, requestedModel);
4947
- if (preferSelfHostedFirst) {
4948
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
4949
- if (selfHostedResponse) {
4950
- return selfHostedResponse;
4951
- }
4952
- }
4953
- // STRATEGY 1: Direct Vigthoria Models API (api.vigthoria.io)
4954
- // Cloud aliases must be charged through Coder wallet gates first.
4955
- if (!this.shouldSkipCloudRoutes(resolvedModel) && !this.isCloudModelId(resolvedModel)) {
4956
- try {
4957
- this.logger.debug(`Direct Vigthoria Models API: ${resolvedModel}`);
4958
- const token = this.getAccessToken();
4959
- const response = await this.modelRouterClient.post('/v1/chat/completions', {
4960
- model: resolvedModel,
4961
- messages,
4962
- max_tokens: this.config.get('preferences').maxTokens,
4963
- temperature: 0.7,
4964
- stream: false,
4965
- }, {
4966
- headers: token ? { Authorization: `Bearer ${token}` } : {},
4967
- });
4968
- if (response.data.choices && response.data.choices.length > 0) {
4969
- const content = response.data.choices[0].message?.content || response.data.choices[0].text;
4970
- if (typeof content !== 'string' || !content.trim()) {
4971
- this.logger.debug(`Direct API returned empty message content for ${resolvedModel}`);
4972
- }
4973
- else {
4974
- return {
4975
- id: response.data.id || `vigthoria-${Date.now()}`,
4976
- message: content,
4977
- model: response.data.model || resolvedModel || requestedModel,
4978
- usage: response.data.usage,
4979
- };
4980
- }
4981
- }
4982
- this.logger.debug(`Direct API returned no choices for ${resolvedModel}`);
4983
- }
4984
- catch (error) {
4985
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
4986
- this.logger.debug(`Direct Vigthoria Models API failed for ${resolvedModel}: ${errMsg}`);
4987
- routeFailures.push(`models:${String(errMsg).slice(0, 120)}`);
4988
- }
4989
- }
4990
- else {
4991
- if (this.isCloudModelId(resolvedModel)) {
4992
- this.logger.debug(`Cloud model ${resolvedModel} routed through Coder wallet gate first; skipping direct models API route.`);
4993
- }
4994
- else {
4995
- this.logger.debug(`Simulating cloud failure for ${resolvedModel}; skipping cloud-backed public API route.`);
4996
- }
4997
- }
4998
- // STRATEGY 2: Vigthoria Cloud API via Coder (authenticated users only)
4999
- if (this.config.isAuthenticated() && !this.shouldSkipCloudRoutes(resolvedModel)) {
5000
- try {
5001
- this.logger.debug(`Vigthoria Cloud API fallback: ${resolvedModel}`);
5002
- const response = await this.client.post('/api/ai/chat', {
5003
- messages,
5004
- model: resolvedModel,
5005
- maxTokens: this.config.get('preferences').maxTokens,
5006
- temperature: 0.7,
5007
- cloudConsent: this.isCloudModelId(resolvedModel),
5008
- });
5009
- if (response.data.success !== false) {
5010
- const content = response.data.response || response.data.message || response.data.content;
5011
- if (typeof content !== 'string' || !content.trim()) {
5012
- this.logger.debug(`Cloud API returned empty message content for ${resolvedModel}`);
5013
- }
5014
- else {
5015
- return {
5016
- id: response.data.id || `vigthoria-coder-${Date.now()}`,
5017
- message: content,
5018
- model: response.data.model || resolvedModel || requestedModel,
5019
- usage: response.data.usage,
5020
- };
5021
- }
5022
- }
5023
- this.logger.debug(`Cloud API returned success=false for ${resolvedModel}`);
5024
- }
5025
- catch (error) {
5026
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5027
- this.logger.debug(`Vigthoria Cloud API failed for ${resolvedModel}: ${errMsg}`);
5028
- routeFailures.push(`coder:${String(errMsg).slice(0, 120)}`);
5029
- }
5030
- try {
5031
- this.logger.debug(`Canonical Vigthoria Cloud fallback: ${resolvedModel}`);
5032
- const token = this.getAccessToken();
5033
- const response = await axios.post('https://coder.vigthoria.io/api/ai/chat', {
5034
- messages,
5035
- model: resolvedModel,
5036
- maxTokens: this.config.get('preferences').maxTokens,
5037
- temperature: 0.7,
5038
- cloudConsent: this.isCloudModelId(resolvedModel),
5039
- }, {
5040
- timeout: 180000,
5041
- httpsAgent: this._httpsAgent ?? undefined,
5042
- headers: token ? { Authorization: `Bearer ${token}`, Cookie: `vigthoria-auth-token=${token}` } : {},
5043
- });
5044
- if (response.data?.success !== false) {
5045
- const content = response.data.response || response.data.message || response.data.content;
5046
- if (typeof content === 'string' && content.trim()) {
5047
- return {
5048
- id: response.data.id || `vigthoria-coder-canonical-${Date.now()}`,
5049
- message: content,
5050
- model: response.data.model || resolvedModel || requestedModel,
5051
- usage: response.data.usage,
5052
- };
5053
- }
5054
- }
5055
- }
5056
- catch (error) {
5057
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5058
- this.logger.debug(`Canonical Vigthoria Cloud fallback failed for ${resolvedModel}: ${errMsg}`);
5059
- routeFailures.push(`coder-canonical:${String(errMsg).slice(0, 120)}`);
5060
- }
5061
- }
5062
- if (!preferSelfHostedFirst) {
5063
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
5064
- if (selfHostedResponse) {
5065
- return selfHostedResponse;
5066
- }
5067
- }
5068
- if (routeFailures.length > 0) {
5069
- this.lastChatTransportErrors = routeFailures;
5070
- }
5071
- return null;
5072
- }
5073
- async trySelfHostedChatWithModel(messages, resolvedModel, requestedModel) {
5074
- if (!this.selfHostedModelRouterClient || !this.shouldTrySelfHostedFallback(resolvedModel, requestedModel)) {
5075
- return null;
5076
- }
5077
- const selfHostedModel = this.getSelfHostedFallbackModelId(resolvedModel, requestedModel);
5078
- try {
5079
- this.logger.debug(`Dedicated Vigthoria model endpoint fallback: ${selfHostedModel}`);
5080
- const response = await this.selfHostedModelRouterClient.post('/v1/chat/completions', {
5081
- model: selfHostedModel,
5082
- messages,
5083
- max_tokens: this.config.get('preferences').maxTokens,
5084
- temperature: 0.7,
5085
- stream: false,
5086
- }, {
5087
- headers: {
5088
- 'x-agent-mode': 'true',
5089
- },
5090
- });
5091
- if (response.data.choices && response.data.choices.length > 0) {
5092
- const content = response.data.choices[0].message?.content || response.data.choices[0].text;
5093
- if (typeof content !== 'string' || !content.trim()) {
5094
- this.logger.debug(`Dedicated Vigthoria model endpoint returned empty message content for ${selfHostedModel}`);
5095
- return null;
5096
- }
5097
- return {
5098
- id: response.data.id || `vigthoria-dedicated-${Date.now()}`,
5099
- message: content,
5100
- model: response.data.model || selfHostedModel,
5101
- usage: response.data.usage,
5102
- };
5103
- }
5104
- this.logger.debug(`Dedicated Vigthoria model endpoint returned no choices for ${selfHostedModel}`);
5105
- }
5106
- catch (error) {
5107
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5108
- this.logger.debug(`Dedicated Vigthoria model endpoint fallback failed for ${selfHostedModel}: ${errMsg}`);
5109
- }
5110
- return null;
5111
- }
5112
- getFallbackModelId(resolvedModel) {
5113
- const cloudModels = new Set([
5114
- 'deepseek-v3.1:671b-cloud',
5115
- 'moonshotai/kimi-k2.5',
5116
- 'vigthoria-cloud-pro',
5117
- 'vigthoria-cloud-k2',
5118
- 'vigthoria-cloud-ultra',
5119
- ]);
5120
- if (cloudModels.has(resolvedModel)) {
5121
- return 'vigthoria-v3-code-35b';
5122
- }
5123
- return null;
5124
- }
5125
- isCloudModelId(resolvedModel) {
5126
- return resolvedModel === 'deepseek-v3.1:671b-cloud'
5127
- || resolvedModel === 'moonshotai/kimi-k2.5'
5128
- || resolvedModel === 'vigthoria-cloud-pro'
5129
- || resolvedModel === 'vigthoria-cloud-k2'
5130
- || resolvedModel === 'vigthoria-cloud-ultra';
5131
- }
5132
- canUseCloudModel() {
5133
- return this.config.hasCloudAccess();
5134
- }
5135
- resolvePermittedModelId(shortName) {
5136
- const normalizedRequested = String(shortName || '').trim().toLowerCase();
5137
- const blockedModels = new Set(['fast', 'mini', 'creative', 'creative-v3', 'creative-v4']);
5138
- if (blockedModels.has(normalizedRequested)) {
5139
- this.logger.debug(`Blocked governed model ${shortName}; using fallback vigthoria-v3-code-35b`);
5140
- return 'vigthoria-v3-code-35b';
5141
- }
5142
- const resolvedModel = this.resolveModelId(shortName);
5143
- if (this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()) {
5144
- const fallbackModel = this.getSelfHostedFallbackModelId(resolvedModel, shortName);
5145
- this.logger.debug(`Blocked unauthorized cloud model ${shortName}; using fallback ${fallbackModel}`);
5146
- return fallbackModel;
5147
- }
5148
- return resolvedModel;
5149
- }
5150
- shouldSimulateCloudFailure() {
5151
- return process.env.VIGTHORIA_SIMULATE_CLOUD_FAILURE === '1';
5152
- }
5153
- shouldTrySelfHostedFallback(resolvedModel, requestedModel) {
5154
- if (!this.selfHostedModelRouterClient) {
5155
- return false;
5156
- }
5157
- const normalizedRequested = String(requestedModel || '').toLowerCase();
5158
- return this.isSelfHostedPreferredModel(resolvedModel, requestedModel)
5159
- || normalizedRequested === 'cloud'
5160
- || normalizedRequested === 'cloud-reason'
5161
- || normalizedRequested === 'ultra';
5162
- }
5163
- isSelfHostedPreferredModel(resolvedModel, requestedModel) {
5164
- const normalizedRequested = String(requestedModel || '').toLowerCase();
5165
- const selfHostedModels = new Set([
5166
- 'vigthoria-v3-code-35b',
5167
- 'vigthoria-v3-code-35b:latest',
5168
- 'vigthoria-v3-code-9b',
5169
- 'vigthoria-v3-code-9b:latest',
5170
- 'vigthoria-v3-balanced-4b',
5171
- 'vigthoria-v3-balanced-4b:latest',
5172
- 'qwen3-coder:latest',
5173
- ]);
5174
- return selfHostedModels.has(resolvedModel)
5175
- || normalizedRequested === 'agent'
5176
- || normalizedRequested === 'code'
5177
- || normalizedRequested === 'code-30b'
5178
- || normalizedRequested === 'code-35b'
5179
- || normalizedRequested === 'code-9b'
5180
- || normalizedRequested === 'balanced'
5181
- || normalizedRequested === 'balanced-4b'
5182
- || normalizedRequested === 'pro';
5183
- }
5184
- getSelfHostedFallbackModelId(resolvedModel, requestedModel) {
5185
- if (this.isSelfHostedPreferredModel(resolvedModel, requestedModel)) {
5186
- return resolvedModel === 'qwen3-coder:latest' ? 'vigthoria-v3-code-35b' : resolvedModel;
5187
- }
5188
- return 'vigthoria-v3-code-35b';
5189
- }
5190
- // Streaming chat
5191
- async *chatStream(messages, model) {
5192
- const wsUrl = this.config.get('wsUrl');
5193
- const token = this.config.get('authToken');
5194
- return new Promise((resolve, reject) => {
5195
- const ws = new WebSocket(`${wsUrl}/chat`, {
5196
- headers: { Authorization: `Bearer ${token}` },
5197
- });
5198
- ws.on('open', () => {
5199
- ws.send(JSON.stringify({
5200
- type: 'chat',
5201
- messages,
5202
- model: this.resolvePermittedModelId(model),
5203
- stream: true,
5204
- }));
5205
- });
5206
- ws.on('message', (data) => {
5207
- const chunk = JSON.parse(data.toString());
5208
- // Yield chunk - but this pattern won't work directly
5209
- // Need to use event emitter pattern instead
5210
- });
5211
- ws.on('error', (error) => {
5212
- reject(error);
5213
- });
5214
- ws.on('close', () => {
5215
- resolve(undefined);
5216
- });
5217
- });
5218
- }
5219
- // Non-streaming alternative with callback
5220
- async chatWithCallback(messages, model, onChunk, onDone, onError) {
5221
- const wsUrl = this.config.get('wsUrl');
5222
- const token = this.config.get('authToken');
5223
- return new Promise((resolve, reject) => {
5224
- const ws = new WebSocket(`${wsUrl}/chat`, {
5225
- headers: { Authorization: `Bearer ${token}` },
5226
- });
5227
- ws.on('open', () => {
5228
- ws.send(JSON.stringify({
5229
- type: 'chat',
5230
- messages,
5231
- model: this.resolvePermittedModelId(model),
5232
- stream: true,
5233
- }));
5234
- });
5235
- ws.on('message', (data) => {
5236
- try {
5237
- const chunk = JSON.parse(data.toString());
5238
- if (chunk.type === 'content') {
5239
- onChunk(chunk.content);
5240
- }
5241
- else if (chunk.type === 'done') {
5242
- onDone();
5243
- ws.close();
5244
- resolve();
5245
- }
5246
- else if (chunk.type === 'error') {
5247
- onError(new Error(chunk.error));
5248
- ws.close();
5249
- reject(new Error(chunk.error));
5250
- }
5251
- }
5252
- catch (e) {
5253
- // Raw text chunk
5254
- onChunk(data.toString());
5255
- }
5256
- });
5257
- ws.on('error', (error) => {
5258
- onError(error);
5259
- reject(error);
5260
- });
5261
- ws.on('close', () => {
5262
- resolve();
5263
- });
5264
- });
5265
- }
5266
- // ─── Chat completion helper ────────────────────────────────────────
5267
- // Routes all AI file commands through the Model Router
5268
- // (/v1/chat/completions on api.vigthoria.io) which is the only
5269
- // backend that reliably accepts our auth token.
5270
- async chatComplete(systemPrompt, userPrompt, model, maxTokens) {
5271
- const resolvedModel = model ? this.resolvePermittedModelId(model) : 'vigthoria-v3-code-35b';
5272
- const response = await this.modelRouterClient.post('/v1/chat/completions', {
5273
- model: resolvedModel,
5274
- messages: [
5275
- { role: 'system', content: systemPrompt },
5276
- { role: 'user', content: userPrompt },
5277
- ],
5278
- max_tokens: maxTokens || this.config.get('preferences').maxTokens || 4096,
5279
- temperature: 0.3,
5280
- stream: false,
5281
- });
5282
- const content = response.data.choices?.[0]?.message?.content || response.data.choices?.[0]?.text || '';
5283
- return typeof content === 'string' ? content : '';
5284
- }
5285
- // Code operations - Using Vigthoria Centralized API (via Model Router)
5286
- async generateCode(prompt, language, model) {
5287
- const isNonHtmlLang = !['html', 'css'].includes(language.toLowerCase());
5288
- const wordCount = prompt.trim().split(/\s+/).length;
5289
- // Prepend a forceful scope-enforcement instruction so the model
5290
- // doesn't expand a small task into an oversized glossy page.
5291
- const buildScopedPrompt = (retry) => {
5292
- if (retry && isNonHtmlLang) {
5293
- // Ultra-minimal retry prompt — no long instruction block
5294
- return `OUTPUT ONLY RAW ${language.toUpperCase()} CODE. MAXIMUM 10 LINES. NO DOM. NO HTML. NO CSS. NO ANIMATION. NO VALIDATION. NO COMMENTS.\n\n${prompt}`;
5295
- }
5296
- const lines = [
5297
- 'IMPORTANT — MANDATORY SCOPE CONSTRAINTS (violation = failure):',
5298
- `1. You MUST output ONLY ${language.toUpperCase()} code. Do NOT output HTML, CSS, or DOM manipulation unless the language is html/css.`,
5299
- `2. The target language is: ${language}. Do not switch to a different language.`,
5300
- '3. Output ONLY what the user explicitly asked for. Nothing more.',
5301
- '4. If the prompt is ≤ 15 words, produce ≤ 80 lines of code maximum.',
5302
- '5. If the user says "tiny", "small", "simple", "minimal", or "basic", produce ≤ 50 lines.',
5303
- '6. NEVER add any of these unless the user EXPLICITLY requests them:',
5304
- ' - Hero sections, CTAs, testimonials, pricing tables, footers, navbars',
5305
- ' - CSS animations, gradients, neon effects, glass-morphism, particles',
5306
- ' - Google Fonts, Font Awesome, external CDN links, icon libraries',
5307
- ' - DOM manipulation, document.createElement, innerHTML',
5308
- ' - Responsive breakpoints, media queries (unless asked)',
5309
- ' - Multiple pages or components when one was requested',
5310
- ' - Input validation, error handling beyond what was requested',
5311
- '7. Prefer inline styles or a small <style> block ONLY if the language is html. No CSS frameworks.',
5312
- '8. Return raw code only — no markdown fences, no explanations, no comments about what could be added.',
5313
- '9. Match the complexity of the request: a "hello world" is 1-5 lines, a "multiply function" is 3-5 lines, a "button" is 5-15 lines.',
5314
- ];
5315
- if (isNonHtmlLang) {
5316
- lines.push(`10. This is a PURE ${language.toUpperCase()} task. ABSOLUTELY NO document.*, window.*, DOM, createElement, innerHTML, querySelector, addEventListener. Output a plain ${language} function/module ONLY.`);
5317
- }
5318
- lines.push('', prompt);
5319
- return lines.join('\n');
5320
- };
5321
- // First attempt — route through Model Router chat completions
5322
- const systemPrompt = `You are a code generator. Output ONLY raw ${language} code. No markdown fences, no explanations, no commentary. Just the code.`;
5323
- let code = await this.chatComplete(systemPrompt, buildScopedPrompt(false), model);
5324
- // Strip markdown fences if model included them
5325
- code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5326
- // Client-side validation: reject DOM-polluted or over-engineered responses for non-HTML languages
5327
- const needsRetry = isNonHtmlLang && (this.codeContainsDomPollution(code) ||
5328
- this.codeIsOverEngineered(code, prompt));
5329
- if (needsRetry) {
5330
- // Retry once with stronger constraint — via Model Router
5331
- code = await this.chatComplete(systemPrompt, buildScopedPrompt(true), model);
5332
- code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5333
- // If still polluted, strip DOM code client-side
5334
- if (this.codeContainsDomPollution(code)) {
5335
- code = this.stripDomPollution(code, language);
5336
- }
5337
- }
5338
- // Final cleanup: for non-HTML, if still over-engineered after retry,
5339
- // extract only the first complete function/class definition.
5340
- if (isNonHtmlLang && this.codeIsOverEngineered(code, prompt)) {
5341
- code = this.extractFirstFunction(code);
5342
- }
5343
- // Ensure balanced braces — model sometimes truncates closing braces
5344
- code = this.ensureBalancedBraces(code);
5345
- return code;
5346
- }
5347
- /**
5348
- * Ensure code has balanced curly braces by appending missing closing braces.
5349
- */
5350
- ensureBalancedBraces(code) {
5351
- // Count braces/parens/brackets outside strings and comments
5352
- let braces = 0, parens = 0, brackets = 0;
5353
- let inStr = null;
5354
- let inLine = false, inBlock = false;
5355
- for (let i = 0; i < code.length; i++) {
5356
- const ch = code[i], nx = code[i + 1] || '';
5357
- if (inLine) {
5358
- if (ch === '\n')
5359
- inLine = false;
5360
- continue;
5361
- }
5362
- if (inBlock) {
5363
- if (ch === '*' && nx === '/') {
5364
- inBlock = false;
5365
- i++;
5366
- }
5367
- continue;
5368
- }
5369
- if (inStr) {
5370
- if (ch === inStr && code[i - 1] !== '\\')
5371
- inStr = null;
5372
- continue;
5373
- }
5374
- if (ch === '/' && nx === '/') {
5375
- inLine = true;
5376
- continue;
5377
- }
5378
- if (ch === '/' && nx === '*') {
5379
- inBlock = true;
5380
- continue;
5381
- }
5382
- if (ch === '"' || ch === "'" || ch === '`') {
5383
- inStr = ch;
5384
- continue;
5385
- }
5386
- if (ch === '{')
5387
- braces++;
5388
- else if (ch === '}')
5389
- braces--;
5390
- else if (ch === '(')
5391
- parens++;
5392
- else if (ch === ')')
5393
- parens--;
5394
- else if (ch === '[')
5395
- brackets++;
5396
- else if (ch === ']')
5397
- brackets--;
5398
- }
5399
- let result = code.trimEnd();
5400
- for (let i = 0; i < braces; i++)
5401
- result += '\n}';
5402
- for (let i = 0; i < parens; i++)
5403
- result += ')';
5404
- for (let i = 0; i < brackets; i++)
5405
- result += ']';
5406
- return braces > 0 || parens > 0 || brackets > 0 ? result : code;
5407
- }
5408
- /**
5409
- * Quick JS/TS syntax validation using Node's built-in parser.
5410
- * Returns true if the code parses without errors.
5411
- */
5412
- validateJsSyntax(code) {
5413
- try {
5414
- // Use Function constructor to check syntax without executing
5415
- new Function(code);
5416
- return true;
5417
- }
5418
- catch {
5419
- return false;
5420
- }
5421
- }
5422
- /**
5423
- * Extract the first complete function/class from code.
5424
- * Used as last-resort when the model keeps over-producing.
5425
- */
5426
- extractFirstFunction(code) {
5427
- const lines = code.split('\n');
5428
- const funcStart = lines.findIndex(l => /^(export\s+)?(const\s+\w+\s*=\s*(\(|function)|function\s+\w+|class\s+\w+|def\s+\w+|fn\s+\w+|func\s+\w+)/.test(l.trim()));
5429
- if (funcStart === -1)
5430
- return code;
5431
- let braceDepth = 0;
5432
- let foundOpen = false;
5433
- const result = [];
5434
- for (let i = funcStart; i < lines.length; i++) {
5435
- const line = lines[i];
5436
- result.push(line);
5437
- braceDepth += (line.match(/{/g) || []).length;
5438
- braceDepth -= (line.match(/}/g) || []).length;
5439
- if (braceDepth > 0)
5440
- foundOpen = true;
5441
- if (foundOpen && braceDepth <= 0) {
5442
- break;
5443
- }
5444
- }
5445
- // If we reached the end without balancing braces, add closing
5446
- if (braceDepth > 0) {
5447
- result.push('};');
5448
- }
5449
- return result.join('\n');
5450
- }
5451
- /**
5452
- * Detect if code is excessively over-engineered for a short prompt.
5453
- * E.g. a "multiply function" request producing 20+ lines.
5454
- */
5455
- codeIsOverEngineered(code, prompt) {
5456
- const wordCount = prompt.trim().split(/\s+/).length;
5457
- const lineCount = code.trim().split('\n').length;
5458
- // Short prompt (≤15 words) should not produce more than 15 lines
5459
- if (wordCount <= 15 && lineCount > 15)
5460
- return true;
5461
- return false;
5462
- }
5463
- /**
5464
- * Detect if generated code contains DOM/HTML pollution inappropriate
5465
- * for a pure programming language like JavaScript, Python, etc.
5466
- */
5467
- codeContainsDomPollution(code) {
5468
- const domPatterns = /document\.(createElement|querySelector|getElementById|getElementsBy|body|head|addEventListener)|innerHTML|\.style\.(cssText|position|transform|animation)|@keyframes|\.appendChild|\.removeChild|window\.(onload|addEventListener)|CSSAnimation|new\s+Animation\b|\.getAnimations\s*\(|\.animate\s*\(/;
5469
- return domPatterns.test(code);
5470
- }
5471
- /**
5472
- * Strip DOM pollution from generated code, keeping only the pure logic.
5473
- * Used as last-resort fallback when the model repeatedly ignores constraints.
5474
- */
5475
- stripDomPollution(code, language) {
5476
- const lines = code.split('\n');
5477
- const cleanLines = [];
5478
- let insideDomBlock = false;
5479
- let braceDepth = 0;
5480
- for (const line of lines) {
5481
- // Detect start of DOM blocks
5482
- if (/document\.|\.style\.|\.appendChild|\.removeChild|\.textContent\s*=|@keyframes|addEventListener|CSSAnimation|\.getAnimations|\.animate\s*\(/.test(line) && !insideDomBlock) {
5483
- insideDomBlock = true;
5484
- braceDepth = 0;
5485
- }
5486
- if (insideDomBlock) {
5487
- braceDepth += (line.match(/{/g) || []).length;
5488
- braceDepth -= (line.match(/}/g) || []).length;
5489
- if (braceDepth <= 0) {
5490
- insideDomBlock = false;
5491
- }
5492
- continue; // Skip DOM lines
5493
- }
5494
- cleanLines.push(line);
5495
- }
5496
- const cleaned = cleanLines.join('\n').trim();
5497
- // If we stripped too aggressively (less than 2 lines left), return a minimal stub
5498
- if (cleaned.split('\n').length < 2) {
5499
- return `// Auto-generated: model returned DOM code for a ${language} task.\n// Please regenerate or provide more specific instructions.`;
5500
- }
5501
- return cleaned;
5502
- }
5503
- // Senior Developer Mode - Planning + Generation + Quality Check
5504
- async generateProject(prompt, projectType, model) {
5505
- const sysPrompt = [
5506
- `You are a senior developer. Generate a complete ${projectType} project.`,
5507
- 'Return a JSON object with these fields:',
5508
- ' "code": the full source code as a string,',
5509
- ' "plan": an object describing the architecture,',
5510
- ' "quality": { "lineCount": number, "score": number (0-100) }',
5511
- 'Return ONLY the JSON object, no markdown fences.',
5512
- ].join('\n');
5513
- const raw = await this.chatComplete(sysPrompt, prompt, model, 8192);
5514
- try {
5515
- const cleaned = raw.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5516
- const parsed = JSON.parse(cleaned);
5517
- return {
5518
- code: parsed.code || raw,
5519
- plan: parsed.plan,
5520
- quality: parsed.quality,
5521
- };
5522
- }
5523
- catch {
5524
- return { code: raw.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim() };
5525
- }
5526
- }
5527
- async explainCode(code, language) {
5528
- const sysPrompt = [
5529
- `You are a code explainer. Explain the following ${language} code clearly and concisely.`,
5530
- 'Focus on what it does, how it works, and any notable patterns or potential issues.',
5531
- 'Format your response as clean Markdown:',
5532
- '- Use ## headers for major sections (e.g. ## Overview, ## How It Works, ## Key Details).',
5533
- '- Use bullet points (- or *) for all lists. Do NOT use numbered lists.',
5534
- '- Wrap code references in backticks.',
5535
- '- Keep paragraphs short (2-3 sentences max).',
5536
- '- Do NOT use raw HTML or excessive blank lines.',
5537
- '- Do NOT nest numbered lists inside sections.',
5538
- ].join('\n');
5539
- return this.chatComplete(sysPrompt, code);
5540
- }
5541
- async reviewCode(code, language) {
5542
- const sysPrompt = [
5543
- `You are a senior code reviewer for ${language}. Analyze the code and return a JSON object with:`,
5544
- ' "score": number 0-100,',
5545
- ' "issues": [{ "type": string, "line": number, "message": string, "severity": "error"|"warning"|"info" }],',
5546
- ' "suggestions": [string]',
5547
- 'Rules:',
5548
- '- Return concrete, line-specific issues with severity.',
5549
- '- Every issue MUST reference a line number.',
5550
- '- Report each distinct bug ONCE. Do NOT report the same bug multiple times with different wording.',
5551
- '- For trivial/short code (< 10 lines), report ONLY actual bugs. Do NOT pad with style, robustness, or best-practice suggestions.',
5552
- '- If you find a real bug (wrong operator, logic error, type mismatch), report ONLY that bug. Do NOT also suggest input validation, type checking, or error handling unless those are ACTUAL bugs.',
5553
- '- Prioritize REAL BUGS: wrong operators, logic errors, off-by-one, type mismatches.',
5554
- '- Do NOT suggest adding error handling, input validation, or documentation as issues unless the user explicitly asked for a style review.',
5555
- '- Return ONLY the JSON object, no markdown fences or extra text.',
5556
- ].join('\n');
5557
- let raw = {};
5558
- try {
5559
- const result = await this.chatComplete(sysPrompt, code);
5560
- const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5561
- raw = JSON.parse(cleaned);
5562
- }
5563
- catch {
5564
- // If parsing fails, return minimal review
5565
- }
5566
- const score = typeof raw.score === 'number' ? raw.score : 0;
5567
- const issues = Array.isArray(raw.issues) ? raw.issues : [];
5568
- const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
5569
- // Merge client-side heuristics, but with tight dedup to avoid
5570
- // redundant over-reporting when the model already found the bug.
5571
- const modelFoundError = issues.some(i => i.severity === 'error');
5572
- const heuristic = this.heuristicCodeIssues(code, language);
5573
- for (const h of heuristic) {
5574
- // If the model already found a real error, skip non-error heuristics
5575
- // entirely — they're just padding (style, robustness, etc.)
5576
- if (modelFoundError && h.severity !== 'error')
5577
- continue;
5578
- // Semantic duplicate check: same line + (similar type OR overlapping
5579
- // keywords in the message). This catches cases where the model
5580
- // and heuristic describe the same bug with different wording.
5581
- const hWords = new Set(h.message.toLowerCase().split(/\W+/).filter(w => w.length > 3));
5582
- const hTypeNorm = h.type.toLowerCase().replace(/[^a-z]/g, '');
5583
- const isSemanticallyDuplicate = issues.some((existing) => {
5584
- if (existing.line !== h.line)
5585
- return false;
5586
- // Normalize types: "logic-error", "logic_error", "logic" all match
5587
- const eTypeNorm = existing.type.toLowerCase().replace(/[^a-z]/g, '');
5588
- if (eTypeNorm === hTypeNorm || eTypeNorm.startsWith(hTypeNorm) || hTypeNorm.startsWith(eTypeNorm))
5589
- return true;
5590
- // Both errors on same line about the same category of problem
5591
- if (existing.severity === 'error' && h.severity === 'error')
5592
- return true;
5593
- // Check keyword overlap — if ≥2 significant words match, it's the same finding
5594
- const eWords = existing.message.toLowerCase().split(/\W+/).filter(w => w.length > 3);
5595
- let overlap = 0;
5596
- for (const w of eWords) {
5597
- if (hWords.has(w))
5598
- overlap++;
5599
- }
5600
- return overlap >= 2;
5601
- });
5602
- if (!isSemanticallyDuplicate) {
5603
- issues.push(h);
5604
- }
5605
- }
5606
- // Sort: errors first, then warnings, then info
5607
- const severityOrder = { error: 0, warning: 1, info: 2 };
5608
- issues.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3));
5609
- // Prevent contradictory output: low score but zero issues.
5610
- if (score < 50 && issues.length === 0) {
5611
- issues.push({
5612
- type: 'quality',
5613
- line: 1,
5614
- message: `The analysis returned a low quality score (${score}/100) but did not enumerate specific issues. Re-run the review or inspect the file manually.`,
5615
- severity: 'warning',
5616
- });
5617
- }
5618
- return { score, issues, suggestions };
3509
+ mcp_context_id: executionContext.mcpContextId || undefined,
3510
+ };
3511
+ return this.v3AgentClient.requestBackground('POST', '', body);
5619
3512
  }
5620
- /**
5621
- * Lightweight client-side heuristic scan: catches common code smells
5622
- * AND logic/arithmetic bugs so review never returns "score 30, no issues".
5623
- */
5624
- heuristicCodeIssues(code, language) {
5625
- const issues = [];
5626
- const lines = code.split('\n');
5627
- for (let i = 0; i < lines.length; i++) {
5628
- const line = lines[i];
5629
- const lineNum = i + 1;
5630
- // console.log left in production code
5631
- if (/\bconsole\.(log|debug|info)\b/.test(line) && !/\/\//.test(line.slice(0, line.indexOf('console')))) {
5632
- issues.push({ type: 'quality', line: lineNum, message: 'console.log/debug statement — remove or replace with proper logging.', severity: 'warning' });
5633
- }
5634
- // TODO/FIXME/HACK comments
5635
- if (/\b(TODO|FIXME|HACK|XXX)\b/.test(line)) {
5636
- issues.push({ type: 'maintainability', line: lineNum, message: `Unresolved ${line.match(/\b(TODO|FIXME|HACK|XXX)\b/)?.[0]} comment.`, severity: 'info' });
5637
- }
5638
- // Empty catch blocks
5639
- if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
5640
- issues.push({ type: 'error-handling', line: lineNum, message: 'Empty catch block — errors are silently swallowed.', severity: 'warning' });
5641
- }
5642
- // Very long lines (> 200 chars)
5643
- if (line.length > 200) {
5644
- issues.push({ type: 'style', line: lineNum, message: `Line exceeds 200 characters (${line.length}).`, severity: 'info' });
5645
- }
5646
- // Arithmetic / logic bugs: function named 'add' but using subtraction
5647
- if (/\breturn\s+\w+\s*-\s*\w+/.test(line)) {
5648
- // Check if the enclosing function name implies addition
5649
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5650
- if (/function\s+add\b/i.test(lines[j]) || /\badd\s*[=(]/.test(lines[j])) {
5651
- issues.push({ type: 'logic', line: lineNum, message: 'Subtraction operator in a function named "add" — likely should be addition (+).', severity: 'error' });
5652
- break;
5653
- }
5654
- }
5655
- }
5656
- // Multiplication where addition is expected
5657
- if (/\breturn\s+\w+\s*\*\s*\w+/.test(line)) {
5658
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5659
- if (/function\s+add\b/i.test(lines[j]) || /\badd\s*[=(]/.test(lines[j])) {
5660
- issues.push({ type: 'logic', line: lineNum, message: 'Multiplication operator in a function named "add" — likely should be addition (+).', severity: 'error' });
5661
- break;
5662
- }
5663
- }
5664
- }
5665
- // Division where subtraction/addition is expected in subtract
5666
- if (/\breturn\s+\w+\s*\+\s*\w+/.test(line)) {
5667
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5668
- if (/function\s+subtract\b/i.test(lines[j]) || /\bsubtract\s*[=(]/.test(lines[j])) {
5669
- issues.push({ type: 'logic', line: lineNum, message: 'Addition operator in a function named "subtract" — likely should be subtraction (-).', severity: 'error' });
5670
- break;
3513
+ async listV3BackgroundJobs(limit = 20) {
3514
+ const data = await this.v3AgentClient.requestBackground('GET', '', undefined, `?limit=${encodeURIComponent(String(limit))}`);
3515
+ return Array.isArray(data.jobs) ? data.jobs : [];
3516
+ }
3517
+ async getV3BackgroundJob(jobId) {
3518
+ return this.v3AgentClient.requestBackground('GET', encodeURIComponent(jobId));
3519
+ }
3520
+ async cancelV3BackgroundJob(jobId) {
3521
+ return this.v3AgentClient.requestBackground('POST', `${encodeURIComponent(jobId)}/cancel`);
3522
+ }
3523
+ async resolveV3BackgroundApproval(jobId, approvalId, approved, allowMode = 'once') {
3524
+ return this.v3AgentClient.resolveApproval({ context_id: jobId, approval_id: approvalId, approved, allow_mode: allowMode });
3525
+ }
3526
+ async getV3BackgroundJobFiles(jobId) {
3527
+ return this.v3AgentClient.requestBackground('GET', `${encodeURIComponent(jobId)}/files`);
3528
+ }
3529
+ async applyV3BackgroundJobFiles(jobId, context = {}) {
3530
+ const [data, job] = await Promise.all([
3531
+ this.getV3BackgroundJobFiles(jobId),
3532
+ this.getV3BackgroundJob(jobId),
3533
+ ]);
3534
+ if (job?.status !== 'completed' || job?.result?.success === false || job?.result?.type === 'error') {
3535
+ throw new Error(`Background job ${jobId} is not a successful completed job and cannot be applied.`);
3536
+ }
3537
+ const mutatedPaths = new Set();
3538
+ const addMutatedPath = (value) => {
3539
+ const normalized = this.normalizeWorkspaceRelativePath(String(value || ''));
3540
+ if (!normalized || path.isAbsolute(normalized) || normalized.split('/').includes('..'))
3541
+ return;
3542
+ mutatedPaths.add(normalized);
3543
+ };
3544
+ for (const filePath of (data.confirmed_mutations || []))
3545
+ addMutatedPath(filePath);
3546
+ for (const event of (Array.isArray(job?.events) ? job.events : [])) {
3547
+ if (event?.type === 'file_mutation' && event?.action !== 'delete')
3548
+ addMutatedPath(event.path);
3549
+ if (event?.type === 'executor_complete') {
3550
+ let summary = event.summary;
3551
+ if (typeof summary === 'string') {
3552
+ try {
3553
+ summary = JSON.parse(summary);
5671
3554
  }
5672
- }
5673
- }
5674
- // Accumulator/reducer bug: subtraction used in a reduce/total/sum context
5675
- // e.g. "sum - item.price" in a function named calculateTotal, getTotal, etc.
5676
- if (/\w+\s*-\s*\w+\.\s*(price|cost|amount|value|total|quantity|count)\b/.test(line) || /\w+\.\s*(price|cost|amount|value|total)\s*-\s*/.test(line)) {
5677
- // Check enclosing function/method name for total/sum/aggregate semantics
5678
- for (let j = i; j >= Math.max(0, i - 10); j--) {
5679
- if (/(function\s+)?(calculate|calc|get|compute|sum|total|aggregate|accumulate)\s*(Total|Sum|Cost|Price|Amount|All)?\s*[=(]/i.test(lines[j]) || /\.(reduce|forEach|map)\s*\(/.test(lines[j])) {
5680
- // Also check if this looks like an accumulator pattern (sum - something instead of sum + something)
5681
- if (/\w+\s*-\s*\w+\.\s*(price|cost|amount|value|total|quantity)\b/.test(line)) {
5682
- issues.push({ type: 'logic', line: lineNum, message: 'Subtraction used in an accumulation context (total/sum/reduce) — likely should be addition (+).', severity: 'error' });
5683
- }
5684
- break;
3555
+ catch {
3556
+ summary = null;
5685
3557
  }
5686
3558
  }
5687
- }
5688
- // Mismatched function name vs operator: sum/total function using wrong operator
5689
- if (/\breturn\b/.test(line)) {
5690
- for (let j = i; j >= Math.max(0, i - 8); j--) {
5691
- const funcMatch = lines[j].match(/function\s+(\w+)/i) || lines[j].match(/(?:const|let|var)\s+(\w+)\s*=/i);
5692
- if (funcMatch) {
5693
- const funcName = funcMatch[1].toLowerCase();
5694
- if ((funcName.includes('total') || funcName.includes('sum') || funcName.includes('add')) && /\s-\s/.test(line) && !/\s\+\s/.test(line)) {
5695
- const alreadyReported = issues.some(iss => iss.line === lineNum && iss.type === 'logic');
5696
- if (!alreadyReported) {
5697
- issues.push({ type: 'logic', line: lineNum, message: `Subtraction operator in "${funcMatch[1]}" — function name implies addition. Likely should use + instead of -.`, severity: 'error' });
5698
- }
5699
- }
5700
- break;
3559
+ if (summary?.status === 'completed') {
3560
+ for (const filePath of [...(summary.changed_files || []), ...(summary.created_files || [])]) {
3561
+ addMutatedPath(filePath);
5701
3562
  }
5702
3563
  }
5703
3564
  }
5704
- // Off-by-one: comparing with === where <= or >= may be needed
5705
- // (not implemented yet — would require more complex AST analysis)
5706
- // Limit to 15 heuristic issues
5707
- if (issues.length >= 15)
5708
- break;
5709
- }
5710
- return issues;
5711
- }
5712
- async fixCode(code, language, fixType) {
5713
- // Client-side syntax pre-check: detect obvious errors and include
5714
- // them in the request so the model has concrete signals.
5715
- const syntaxHints = fixType === 'bugs' || fixType === 'syntax' ? this.detectSyntaxErrors(code, language) : '';
5716
- // Run heuristic logic analysis, but ONLY pass hints relevant to the fixType
5717
- const heuristicIssues = this.heuristicCodeIssues(code, language);
5718
- const relevantIssues = heuristicIssues.filter(issue => {
5719
- if (fixType === 'bugs' || fixType === 'logic')
5720
- return issue.type === 'logic' || issue.severity === 'error';
5721
- if (fixType === 'syntax')
5722
- return issue.type !== 'logic' && issue.severity === 'error';
5723
- if (fixType === 'style')
5724
- return issue.type === 'style' || issue.type === 'quality';
5725
- if (fixType === 'security')
5726
- return issue.type === 'security';
5727
- if (fixType === 'performance')
5728
- return issue.type === 'performance';
5729
- return true; // 'all' or unknown fixType: include everything
5730
- });
5731
- const logicHints = relevantIssues
5732
- .map(i => `Line ${i.line}: [${i.type}] ${i.message}`)
5733
- .join('\n// ');
5734
- const allHints = [syntaxHints, logicHints].filter(Boolean).join('\n// ');
5735
- // Build a constraint preamble that's stricter for syntax-only mode.
5736
- let preamble;
5737
- if (fixType === 'syntax') {
5738
- preamble = allHints
5739
- ? `// SYNTAX ERRORS DETECTED BY CLIENT:\n// ${allHints}\n// RULES: Fix ONLY bracket/parenthesis/brace mismatches and keyword typos. Do NOT rename functions, do NOT add comments, do NOT restructure or reformat. Output the corrected code ONLY.\n\n`
5740
- : '';
5741
- }
5742
- else {
5743
- preamble = allHints
5744
- ? `// BUGS DETECTED BY STATIC ANALYSIS — YOU MUST FIX THESE:\n// ${allHints}\n// IMPORTANT: Fix ONLY these specific bugs. Do not add comments, do not restructure the code, do not add or remove lines beyond the minimal fix.\n\n`
5745
- : '';
5746
- }
5747
- const augmentedCode = preamble ? `${preamble}${code}` : code;
5748
- const sysPrompt = [
5749
- `You are a ${language} code fixer. Fix the code for: ${fixType}.`,
5750
- 'Return a JSON object with:',
5751
- ' "fixed": the COMPLETE corrected source code as a string (not a snippet — the full file),',
5752
- ' "changes": [{ "line": number, "before": string, "after": string, "reason": string }]',
5753
- 'Rules:',
5754
- '- The "fixed" field MUST contain the entire corrected source code with ALL lines, including unchanged lines.',
5755
- '- The "fixed" code MUST have balanced braces, parentheses, and brackets.',
5756
- '- Fix ONLY the issues related to the fix type.',
5757
- '- Do not add comments, do not restructure beyond the minimal fix.',
5758
- '- Return ONLY the JSON object, no markdown fences.',
5759
- ].join('\n');
5760
- let raw = {};
5761
- try {
5762
- const result = await this.chatComplete(sysPrompt, augmentedCode);
5763
- const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5764
- raw = JSON.parse(cleaned);
5765
- }
5766
- catch {
5767
- // If parsing fails, fall through to client-side handling
5768
- }
5769
- let fixed = typeof raw.fixed === 'string' ? raw.fixed : (typeof raw.code === 'string' ? raw.code : code);
5770
- let changes = Array.isArray(raw.changes) ? raw.changes : [];
5771
- // If server returned no changes but we found issues, strip
5772
- // our injected comment prefix from the returned code and attempt
5773
- // a basic client-side repair.
5774
- if (changes.length === 0 && preamble && fixed === augmentedCode) {
5775
- fixed = code; // restore original
5776
- }
5777
- // Strip the injected comment block if it leaked into the output
5778
- if (fixed.startsWith('// BUGS DETECTED BY STATIC ANALYSIS') || fixed.startsWith('// SYNTAX ERRORS DETECTED BY CLIENT')) {
5779
- const idx = fixed.indexOf('\n\n');
5780
- if (idx !== -1) {
5781
- fixed = fixed.slice(idx + 2);
5782
- }
5783
3565
  }
5784
- // For syntax-only fixes, strip any comments the model added that
5785
- // weren't in the original code (e.g. "// Fixed mismatched parentheses").
5786
- if (fixType === 'syntax' && fixed !== code) {
5787
- fixed = this.stripInjectedComments(code, fixed, language);
5788
- }
5789
- // Safety net: for syntax fixes, ensure the fix didn't make bracket
5790
- // balance worse. If the original had more closing delimiters than
5791
- // the fix, append the missing ones.
5792
- if (fixType === 'syntax' && fixed !== code) {
5793
- fixed = this.repairBracketBalance(code, fixed);
5794
- }
5795
- // Final bracket-balance guarantee — ensure the emitted code has
5796
- // balanced braces/parens/brackets regardless of what the model returned.
5797
- fixed = this.ensureBalancedBraces(fixed);
5798
- // For JS/TS syntax fixes, validate the output actually parses.
5799
- // If it doesn't, attempt a more aggressive bracket repair.
5800
- if ((fixType === 'syntax' || fixType === 'bugs') && fixed !== code) {
5801
- const lang = language.toLowerCase();
5802
- if (['javascript', 'js', 'typescript', 'ts'].includes(lang)) {
5803
- if (!this.validateJsSyntax(fixed)) {
5804
- // Try once more: strip any remaining injected comments and re-balance
5805
- let repaired = this.stripInjectedComments(code, fixed, language);
5806
- repaired = this.ensureBalancedBraces(repaired);
5807
- if (this.validateJsSyntax(repaired)) {
5808
- fixed = repaired;
5809
- }
5810
- // If still invalid, return the best-effort fix — better than
5811
- // silently reverting to the original broken code.
5812
- }
5813
- }
3566
+ if (mutatedPaths.size === 0) {
3567
+ throw new Error(`Background job ${jobId} has no confirmed successful file mutations to apply.`);
5814
3568
  }
5815
- // If there are still no changes but the fixed code differs, compute
5816
- // a semantic diff using LCS so inserted/removed lines don't cause
5817
- // every subsequent line to appear as changed.
5818
- if (changes.length === 0 && fixed !== code) {
5819
- // Use a clean reason string — strip verbose static-analysis hints
5820
- const cleanReason = relevantIssues.length > 0
5821
- ? relevantIssues.map(i => i.message).join('; ')
5822
- : 'AI-suggested fix';
5823
- changes = this.computeSemanticDiff(code, fixed, cleanReason);
3569
+ const rootPath = this.resolveAgentTargetPath({
3570
+ ...context,
3571
+ workspacePath: context.workspacePath || data.local_workspace_path,
3572
+ projectPath: context.projectPath || data.local_workspace_path,
3573
+ targetPath: context.targetPath || data.local_workspace_path,
3574
+ });
3575
+ if (!rootPath) {
3576
+ throw new Error('No local workspace path available for applying background job files.');
5824
3577
  }
5825
- return { fixed, changes };
5826
- }
5827
- /**
5828
- * Compute a semantic diff between original and fixed code using
5829
- * Longest Common Subsequence (LCS) to avoid the line-shift inflation
5830
- * bug where inserting one line flags all subsequent lines as changed.
5831
- */
5832
- computeSemanticDiff(original, fixed, reason) {
5833
- const origLines = original.split('\n');
5834
- const fixedLines = fixed.split('\n');
5835
- const changes = [];
5836
- // Build LCS table
5837
- const m = origLines.length;
5838
- const n = fixedLines.length;
5839
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
5840
- for (let i = 1; i <= m; i++) {
5841
- for (let j = 1; j <= n; j++) {
5842
- if (origLines[i - 1] === fixedLines[j - 1]) {
5843
- dp[i][j] = dp[i - 1][j - 1] + 1;
5844
- }
5845
- else {
5846
- dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
5847
- }
3578
+ const applied = [];
3579
+ const skipped = [];
3580
+ for (const [relativePath, content] of Object.entries(data.files || {})) {
3581
+ const normalizedRelativePath = this.normalizeWorkspaceRelativePath(relativePath);
3582
+ if (!mutatedPaths.has(normalizedRelativePath)) {
3583
+ skipped.push(relativePath);
3584
+ continue;
5848
3585
  }
5849
- }
5850
- // Backtrack to find the diff
5851
- let i = m, j = n;
5852
- const ops = [];
5853
- while (i > 0 || j > 0) {
5854
- if (i > 0 && j > 0 && origLines[i - 1] === fixedLines[j - 1]) {
5855
- ops.push({ type: 'keep', origLine: i, fixedLine: j, text: origLines[i - 1] });
5856
- i--;
5857
- j--;
3586
+ if (typeof content !== 'string') {
3587
+ skipped.push(relativePath);
3588
+ continue;
5858
3589
  }
5859
- else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
5860
- ops.push({ type: 'add', fixedLine: j, text: fixedLines[j - 1] });
5861
- j--;
3590
+ if (this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath)) {
3591
+ applied.push(relativePath);
5862
3592
  }
5863
3593
  else {
5864
- ops.push({ type: 'remove', origLine: i, text: origLines[i - 1] });
5865
- i--;
5866
- }
5867
- }
5868
- ops.reverse();
5869
- // Merge adjacent remove+add pairs into single change entries
5870
- let idx = 0;
5871
- while (idx < ops.length) {
5872
- const op = ops[idx];
5873
- if (op.type === 'remove') {
5874
- // Look ahead for a matching 'add' immediately after
5875
- if (idx + 1 < ops.length && ops[idx + 1].type === 'add') {
5876
- changes.push({
5877
- line: op.origLine,
5878
- before: op.text,
5879
- after: ops[idx + 1].text,
5880
- reason: reason || 'AI-suggested fix',
5881
- });
5882
- idx += 2;
5883
- continue;
5884
- }
5885
- changes.push({
5886
- line: op.origLine,
5887
- before: op.text,
5888
- after: '(line removed)',
5889
- reason: reason || 'AI-suggested fix',
5890
- });
5891
- }
5892
- else if (op.type === 'add') {
5893
- changes.push({
5894
- line: op.fixedLine,
5895
- before: '(new line)',
5896
- after: op.text,
5897
- reason: reason || 'AI-suggested fix',
5898
- });
3594
+ skipped.push(relativePath);
5899
3595
  }
5900
- idx++;
5901
- }
5902
- if (changes.length === 0) {
5903
- changes.push({ line: 1, before: '(whitespace changes)', after: '(see fixed file)', reason: reason || 'AI-suggested fix' });
5904
3596
  }
5905
- return changes;
3597
+ return { applied, skipped, status: data.status };
5906
3598
  }
5907
- /**
5908
- * Lightweight client-side syntax error detection.
5909
- * Returns a human-readable description of obvious errors, or empty string.
5910
- */
5911
- detectSyntaxErrors(code, language) {
5912
- const lang = language.toLowerCase();
5913
- const errors = [];
5914
- if (lang === 'javascript' || lang === 'typescript' || lang === 'js' || lang === 'ts') {
5915
- // Bracket matching
5916
- let braces = 0, brackets = 0, parens = 0;
5917
- // Track string context (simple heuristic — not a full parser)
5918
- let inString = null;
5919
- let inLineComment = false;
5920
- let inBlockComment = false;
5921
- for (let i = 0; i < code.length; i++) {
5922
- const ch = code[i];
5923
- const next = code[i + 1] || '';
5924
- if (inLineComment) {
5925
- if (ch === '\n')
5926
- inLineComment = false;
5927
- continue;
5928
- }
5929
- if (inBlockComment) {
5930
- if (ch === '*' && next === '/') {
5931
- inBlockComment = false;
5932
- i++;
5933
- }
5934
- continue;
5935
- }
5936
- if (inString) {
5937
- if (ch === inString && code[i - 1] !== '\\')
5938
- inString = null;
5939
- continue;
5940
- }
5941
- if (ch === '/' && next === '/') {
5942
- inLineComment = true;
5943
- continue;
5944
- }
5945
- if (ch === '/' && next === '*') {
5946
- inBlockComment = true;
5947
- continue;
5948
- }
5949
- if (ch === '"' || ch === "'" || ch === '`') {
5950
- inString = ch;
5951
- continue;
5952
- }
5953
- if (ch === '{')
5954
- braces++;
5955
- else if (ch === '}')
5956
- braces--;
5957
- else if (ch === '[')
5958
- brackets++;
5959
- else if (ch === ']')
5960
- brackets--;
5961
- else if (ch === '(')
5962
- parens++;
5963
- else if (ch === ')')
5964
- parens--;
5965
- }
5966
- if (braces !== 0)
5967
- errors.push(`Mismatched braces (balance: ${braces > 0 ? '+' : ''}${braces})`);
5968
- if (brackets !== 0)
5969
- errors.push(`Mismatched brackets (balance: ${brackets > 0 ? '+' : ''}${brackets})`);
5970
- if (parens !== 0)
5971
- errors.push(`Mismatched parentheses (balance: ${parens > 0 ? '+' : ''}${parens})`);
5972
- // Detect obvious keyword typos
5973
- const lines = code.split('\n');
5974
- for (let li = 0; li < lines.length; li++) {
5975
- const line = lines[li];
5976
- if (/\bfuncion\b|\bfuction\b|\bfuntion\b/i.test(line))
5977
- errors.push(`Line ${li + 1}: Misspelled 'function'`);
5978
- if (/\bretrun\b/i.test(line))
5979
- errors.push(`Line ${li + 1}: Misspelled 'return'`);
5980
- if (/\bconts\b|\bcosnt\b/i.test(line))
5981
- errors.push(`Line ${li + 1}: Misspelled 'const'`);
5982
- }
3599
+ async runOperatorWorkflow(message, context = {}) {
3600
+ const executionContext = await this.bindExecutionContext(context);
3601
+ const requestedOperatorTimeoutMs = Number(context.operatorTimeoutMs ?? DEFAULT_OPERATOR_TIMEOUT_MS);
3602
+ const timeoutMs = Number.isFinite(requestedOperatorTimeoutMs) && requestedOperatorTimeoutMs > 0
3603
+ ? requestedOperatorTimeoutMs
3604
+ : 0;
3605
+ const workspacePath = executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || process.cwd();
3606
+ const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
3607
+ const operatorWorkspaceAlias = `vigthoria://workspace/${encodeURIComponent(path.basename(workspacePath))}`;
3608
+ const requestedModel = String(executionContext.model || 'code');
3609
+ const resolvedModel = this.resolveModelId(requestedModel);
3610
+ try {
3611
+ return await this.operatorClient.run({
3612
+ message,
3613
+ executionContext,
3614
+ workspaceAlias: operatorWorkspaceAlias,
3615
+ workspaceSummary,
3616
+ requestedModel,
3617
+ resolvedModel,
3618
+ timeoutMs,
3619
+ onStreamEvent: typeof context.onStreamEvent === 'function' ? context.onStreamEvent : undefined,
3620
+ });
5983
3621
  }
5984
- if (lang === 'python' || lang === 'py') {
5985
- const lines = code.split('\n');
5986
- for (let li = 0; li < lines.length; li++) {
5987
- const line = lines[li];
5988
- if (/^\s*(def|class|if|elif|else|for|while|try|except|with)\b/.test(line) && !line.trimEnd().endsWith(':') && !line.trimEnd().endsWith('\\')) {
5989
- errors.push(`Line ${li + 1}: Missing colon after '${line.trim().split(/\s/)[0]}'`);
5990
- }
3622
+ catch (error) {
3623
+ if (error instanceof OperatorClientError) {
3624
+ throw new CLIError(error.message, error.category, { cause: error });
5991
3625
  }
3626
+ throw error;
5992
3627
  }
5993
- return errors.join('; ');
5994
3628
  }
5995
3629
  /**
5996
- * Strip comment lines that the model added during a fix but were not
5997
- * present in the original code. Used for syntax-only fixes where the
5998
- * model tends to annotate its changes with "// Fixed ..." comments.
3630
+ * Chat API - Direct Vigthoria Models API Architecture
3631
+ *
3632
+ * PRIMARY: api.vigthoria.io - Direct access to Vigthoria Model Router
3633
+ * This is the centralized public API for all AI operations.
3634
+ * Works for both authenticated and unauthenticated users.
3635
+ *
3636
+ * FALLBACK: Coder Cloud API - For authenticated users only
3637
+ *
3638
+ * NO localhost fallbacks - CLI is for external users, not server-side!
5999
3639
  */
6000
- stripInjectedComments(original, fixed, language) {
6001
- const lang = language.toLowerCase();
6002
- // Only handle JS/TS/Python single-line comment patterns for now
6003
- let wholeLineRe;
6004
- let inlineRe;
6005
- if (lang === 'python' || lang === 'py') {
6006
- wholeLineRe = /^\s*#\s/;
6007
- inlineRe = /\s+#\s.*$/;
6008
- }
6009
- else {
6010
- wholeLineRe = /^\s*\/\/\s/;
6011
- inlineRe = /\s+\/\/\s.*$/;
6012
- }
6013
- const origLines = original.split('\n');
6014
- const origSet = new Set(origLines.map(l => l.trim()));
6015
- const fixedLines = fixed.split('\n');
6016
- const result = [];
6017
- for (let idx = 0; idx < fixedLines.length; idx++) {
6018
- let line = fixedLines[idx];
6019
- // Remove whole-line comments not in original
6020
- if (wholeLineRe.test(line) && !origSet.has(line.trim())) {
6021
- continue;
6022
- }
6023
- // Strip inline trailing comments the model added.
6024
- // Only strip if the corresponding original line at the same position
6025
- // didn't have an inline comment at all.
6026
- if (inlineRe.test(line)) {
6027
- const origLine = idx < origLines.length ? origLines[idx] : '';
6028
- if (!inlineRe.test(origLine)) {
6029
- line = line.replace(inlineRe, '');
6030
- }
6031
- }
6032
- result.push(line);
6033
- }
6034
- return result.join('\n');
3640
+ async chat(messages, model, _useLocal = false) {
3641
+ const response = await this.modelTransportService.chat(messages, model);
3642
+ this.lastChatTransportErrors = this.modelTransportService.getLastErrors();
3643
+ if (response)
3644
+ return response;
3645
+ if (this.lastChatTransportErrors.length > 0)
3646
+ this.logger.debug(`Chat transport failures: ${this.lastChatTransportErrors.slice(0, 4).join(' | ')}`);
3647
+ throw new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend');
6035
3648
  }
6036
- /**
6037
- * Ensure the fixed code hasn't lost closing delimiters relative to the
6038
- * original. Counts {, }, (, ), [, ] outside strings/comments and if
6039
- * the fix has fewer closers than the original, appends the missing ones.
6040
- */
6041
- repairBracketBalance(original, fixed) {
6042
- const count = (src) => {
6043
- let braces = 0, parens = 0, brackets = 0;
6044
- let inStr = null;
6045
- let inLine = false, inBlock = false;
6046
- for (let i = 0; i < src.length; i++) {
6047
- const ch = src[i], nx = src[i + 1] || '';
6048
- if (inLine) {
6049
- if (ch === '\n')
6050
- inLine = false;
6051
- continue;
6052
- }
6053
- if (inBlock) {
6054
- if (ch === '*' && nx === '/') {
6055
- inBlock = false;
6056
- i++;
6057
- }
6058
- continue;
6059
- }
6060
- if (inStr) {
6061
- if (ch === inStr && src[i - 1] !== '\\')
6062
- inStr = null;
6063
- continue;
6064
- }
6065
- if (ch === '/' && nx === '/') {
6066
- inLine = true;
6067
- continue;
6068
- }
6069
- if (ch === '/' && nx === '*') {
6070
- inBlock = true;
6071
- continue;
6072
- }
6073
- if (ch === '"' || ch === "'" || ch === '`') {
6074
- inStr = ch;
6075
- continue;
6076
- }
6077
- if (ch === '{')
6078
- braces++;
6079
- else if (ch === '}')
6080
- braces--;
6081
- else if (ch === '(')
6082
- parens++;
6083
- else if (ch === ')')
6084
- parens--;
6085
- else if (ch === '[')
6086
- brackets++;
6087
- else if (ch === ']')
6088
- brackets--;
6089
- }
6090
- return { braces, parens, brackets };
6091
- };
6092
- const orig = count(original);
6093
- const fix = count(fixed);
6094
- const append = [];
6095
- // If original was balanced (or close) but fix is missing closers,
6096
- // append them. Only act when the fix is *more unbalanced* than original.
6097
- if (fix.braces > orig.braces) {
6098
- for (let i = 0; i < fix.braces - orig.braces; i++)
6099
- append.push('}');
6100
- }
6101
- if (fix.parens > orig.parens) {
6102
- for (let i = 0; i < fix.parens - orig.parens; i++)
6103
- append.push(')');
6104
- }
6105
- if (fix.brackets > orig.brackets) {
6106
- for (let i = 0; i < fix.brackets - orig.brackets; i++)
6107
- append.push(']');
6108
- }
6109
- if (append.length > 0) {
6110
- return fixed.trimEnd() + '\n' + append.join('\n');
6111
- }
6112
- return fixed;
3649
+ resolvePermittedModelId(shortName) {
3650
+ return this.modelGovernance.resolvePermittedModelId(shortName);
3651
+ }
3652
+ // ─── Chat completion helper ────────────────────────────────────────
3653
+ // Routes all AI file commands through the Model Router
3654
+ // (/v1/chat/completions on api.vigthoria.io) which is the only
3655
+ // backend that reliably accepts our auth token.
3656
+ async chatComplete(systemPrompt, userPrompt, model, maxTokens) {
3657
+ return this.modelTransportService.complete(systemPrompt, userPrompt, model, maxTokens);
3658
+ }
3659
+ // Compatibility facade for code generation/review commands.
3660
+ generateCode(prompt, language, model) {
3661
+ return this.codeOperations.generateCode(prompt, language, model);
3662
+ }
3663
+ generateProject(prompt, projectType, model) {
3664
+ return this.codeOperations.generateProject(prompt, projectType, model);
3665
+ }
3666
+ explainCode(code, language) {
3667
+ return this.codeOperations.explainCode(code, language);
3668
+ }
3669
+ reviewCode(code, language) {
3670
+ return this.codeOperations.reviewCode(code, language);
3671
+ }
3672
+ fixCode(code, language, fixType) {
3673
+ return this.codeOperations.fixCode(code, language, fixType);
6113
3674
  }
6114
3675
  // Model resolution - maps Vigthoria model names to internal IDs
6115
3676
  // INTERNAL USE ONLY - users see only Vigthoria branding
6116
3677
  resolveModelId(shortName) {
6117
- const modelMap = {
6118
- // ═══════════════════════════════════════════════════════════════
6119
- // Vigthoria server infrastructure models
6120
- // ═══════════════════════════════════════════════════════════════
6121
- 'fast': 'vigthoria-v3-balanced-4b',
6122
- 'mini': 'vigthoria-mini-0.6b',
6123
- 'balanced': 'vigthoria-v3-balanced-4b',
6124
- 'balanced-4b': 'vigthoria-v3-balanced-4b',
6125
- 'creative': 'vigthoria-creative-9b-v4',
6126
- // Code Models - 35B is the default powerhouse
6127
- 'code': 'vigthoria-v3-code-35b', // Vigthoria server infrastructure 35B model
6128
- 'code-30b': 'vigthoria-v3-code-35b',
6129
- 'code-35b': 'vigthoria-v3-code-35b',
6130
- 'code-8b': 'vigthoria-v3-code-9b',
6131
- 'code-9b': 'vigthoria-v3-code-9b',
6132
- 'pro': 'vigthoria-v3-code-35b',
6133
- 'agent': 'vigthoria-v3-code-35b',
6134
- 'vigthoria-code': 'vigthoria-v3-code-35b',
6135
- 'vigthoria-agent': 'vigthoria-v3-code-35b',
6136
- // ═══════════════════════════════════════════════════════════════
6137
- // VIGTHORIA CLOUD - current billing catalog aliases
6138
- // ═══════════════════════════════════════════════════════════════
6139
- 'cloud': 'vigthoria-cloud-balanced',
6140
- 'cloud-code': 'vigthoria-cloud-code',
6141
- 'cloud-balanced': 'vigthoria-cloud-balanced',
6142
- 'cloud-fast': 'vigthoria-cloud-fast',
6143
- 'cloud-power': 'vigthoria-cloud-power',
6144
- 'cloud-maximum': 'vigthoria-cloud-maximum',
6145
- 'cloud-reason': 'vigthoria-cloud-power',
6146
- 'ultra': 'vigthoria-cloud-maximum',
6147
- // Legacy aliases kept for backward compatibility
6148
- 'cloud-pro': 'vigthoria-cloud-power',
6149
- 'cloud-k2': 'vigthoria-cloud-power',
6150
- 'cloud-ultra': 'vigthoria-cloud-maximum',
6151
- };
6152
- // If already a full model ID, return as-is
6153
- if (shortName.includes('vigthoria') || shortName.includes('/') || shortName.includes(':')) {
6154
- if (modelMap[shortName]) {
6155
- return modelMap[shortName];
6156
- }
6157
- return shortName;
6158
- }
6159
- return modelMap[shortName] || 'vigthoria-v3-code-35b';
3678
+ return this.modelGovernance.resolveModelId(shortName);
6160
3679
  }
6161
3680
  async getCoderHealth() {
6162
3681
  try {
6163
- const response = await this.client.get('/api/health', { timeout: 5000 });
3682
+ const response = await this.client.get('/api/health', { timeout: 12000 });
6164
3683
  const ok = response.data?.status === 'ok' || response.data?.healthy === true;
6165
3684
  return {
6166
3685
  name: 'Coder API',
@@ -6183,75 +3702,10 @@ document.addEventListener('DOMContentLoaded', () => {
6183
3702
  * so users see a clear pass/fail before "Planning..." hangs on slow routes.
6184
3703
  */
6185
3704
  async runChatModelPreflight(requestedModel = 'agent') {
6186
- const routes = [];
6187
- const probes = [
6188
- {
6189
- name: 'Vigthoria Coder API',
6190
- run: async () => {
6191
- const health = await this.getCoderHealth();
6192
- return { ok: health.ok, error: health.error };
6193
- },
6194
- },
6195
- {
6196
- name: 'Vigthoria Models API',
6197
- run: async () => {
6198
- const health = await this.getModelsHealth();
6199
- return { ok: health.ok, error: health.error };
6200
- },
6201
- },
6202
- ];
6203
- const selfHostedHealth = await this.getSelfHostedHealth();
6204
- if (selfHostedHealth) {
6205
- const sh = selfHostedHealth;
6206
- probes.unshift({
6207
- name: sh.name || 'Dedicated Models API',
6208
- run: async () => ({ ok: sh.ok, error: sh.error }),
6209
- });
6210
- }
6211
- if (this.isSelfHostedPreferredModel(this.resolveModelId(requestedModel), requestedModel)) {
6212
- // Agent/code models should also accept V3 health as a usable inference path.
6213
- probes.push({
6214
- name: 'V3 Agent API',
6215
- run: async () => {
6216
- const v3 = await this.runV3HealthCheck();
6217
- return { ok: v3.healthy, error: v3.error };
6218
- },
6219
- });
6220
- }
6221
- const results = await Promise.all(probes.map(async (probe) => {
6222
- try {
6223
- const result = await probe.run();
6224
- routes.push({ name: probe.name, ok: result.ok, error: result.error });
6225
- return result.ok;
6226
- }
6227
- catch (error) {
6228
- const message = error?.message || String(error);
6229
- routes.push({ name: probe.name, ok: false, error: message });
6230
- return false;
6231
- }
6232
- }));
6233
- const healthy = results.some(Boolean);
6234
- const inferenceRoutes = routes.filter((route) => route.name !== 'V3 Agent API');
6235
- const firstHealthy = inferenceRoutes.find((route) => route.ok) || routes.find((route) => route.ok);
6236
- return {
6237
- healthy,
6238
- endpoint: firstHealthy?.name || 'api.vigthoria.io',
6239
- error: healthy
6240
- ? undefined
6241
- : 'No Vigthoria model backend responded during preflight. Run `vigthoria login` and check your internet connection.',
6242
- routes,
6243
- };
3705
+ return this.capabilityHealthService.runModelPreflight(requestedModel);
6244
3706
  }
6245
3707
  mapPreflightEndpointToRoute(endpoint) {
6246
- const normalized = String(endpoint || '').toLowerCase();
6247
- if (normalized.includes('coder'))
6248
- return 'coder';
6249
- if (normalized.includes('models'))
6250
- return 'models';
6251
- if (normalized.includes('dedicated') || normalized.includes('self-hosted') || normalized.includes('selfhosted')) {
6252
- return 'selfhosted';
6253
- }
6254
- return null;
3708
+ return this.capabilityHealthService.mapPreflightEndpoint(endpoint);
6255
3709
  }
6256
3710
  getLastChatTransportErrors() {
6257
3711
  return this.lastChatTransportErrors;
@@ -6291,7 +3745,7 @@ document.addEventListener('DOMContentLoaded', () => {
6291
3745
  error: String(result.error || ''),
6292
3746
  });
6293
3747
  for (let attempt = 0; attempt < 3; attempt += 1) {
6294
- const response = await fetch(endpoint, {
3748
+ const response = await this.fetch(endpoint, {
6295
3749
  method: 'POST',
6296
3750
  headers: { ...headers, 'Content-Type': 'application/json' },
6297
3751
  body,
@@ -6335,7 +3789,7 @@ document.addEventListener('DOMContentLoaded', () => {
6335
3789
  const candidates = ['/v1/models', '/models', '/api/models', '/api/tags'];
6336
3790
  for (const endpoint of candidates) {
6337
3791
  try {
6338
- const response = await client.get(endpoint, { timeout: 5000 });
3792
+ const response = await client.get(endpoint, { timeout: 12000 });
6339
3793
  const modelCount = this.extractModelCount(response.data);
6340
3794
  if (modelCount > 0) {
6341
3795
  return { modelCount, endpoint };
@@ -6351,7 +3805,7 @@ document.addEventListener('DOMContentLoaded', () => {
6351
3805
  const modelsApiUrl = this.config.get('modelsApiUrl');
6352
3806
  try {
6353
3807
  const [healthResponse, modelProbe] = await Promise.all([
6354
- this.modelRouterClient.get('/health', { timeout: 5000 }),
3808
+ this.modelRouterClient.get('/health', { timeout: 12000 }),
6355
3809
  this.probeModelList(this.modelRouterClient),
6356
3810
  ]);
6357
3811
  const healthOk = this.isHealthyServicePayload(healthResponse.data);
@@ -6384,7 +3838,7 @@ document.addEventListener('DOMContentLoaded', () => {
6384
3838
  }
6385
3839
  try {
6386
3840
  const [healthResponse, modelProbe] = await Promise.all([
6387
- this.selfHostedModelRouterClient.get('/health', { timeout: 5000 }),
3841
+ this.selfHostedModelRouterClient.get('/health', { timeout: 12000 }),
6388
3842
  this.probeModelList(this.selfHostedModelRouterClient),
6389
3843
  ]);
6390
3844
  const healthOk = this.isHealthyServicePayload(healthResponse.data);
@@ -6422,8 +3876,8 @@ document.addEventListener('DOMContentLoaded', () => {
6422
3876
  for (const endpoint of candidates) {
6423
3877
  try {
6424
3878
  const controller = new AbortController();
6425
- const timer = setTimeout(() => controller.abort(), 3000);
6426
- const response = await fetch(endpoint, {
3879
+ const timer = setTimeout(() => controller.abort(), 12000);
3880
+ const response = await this.fetch(endpoint, {
6427
3881
  method: 'GET',
6428
3882
  headers,
6429
3883
  signal: controller.signal,
@@ -6471,7 +3925,7 @@ document.addEventListener('DOMContentLoaded', () => {
6471
3925
  try {
6472
3926
  const controller = new AbortController();
6473
3927
  const timer = setTimeout(() => controller.abort(), 2000);
6474
- const probe = await fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
3928
+ const probe = await this.fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
6475
3929
  clearTimeout(timer);
6476
3930
  if (probe.ok || probe.status === 204 || probe.status === 405) {
6477
3931
  return {
@@ -6497,9 +3951,13 @@ document.addEventListener('DOMContentLoaded', () => {
6497
3951
  const endpoint = process.env.VIGTHORIA_HYPERLOOP_URL || `${configuredApiUrl}/api/hyperloop/health`;
6498
3952
  try {
6499
3953
  const token = this.getAccessToken();
3954
+ const hyperLoopServiceKey = process.env.VIGTHORIA_HYPERLOOP_SERVICE_KEY || process.env.HYPERLOOP_SERVICE_KEY || '';
6500
3955
  const response = await fetchWithServiceTimeout(endpoint, {
6501
3956
  method: 'GET',
6502
- headers: token ? { Authorization: `Bearer ${token}` } : undefined,
3957
+ headers: {
3958
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
3959
+ ...(hyperLoopServiceKey ? { 'X-Service-Key': hyperLoopServiceKey } : {}),
3960
+ },
6503
3961
  });
6504
3962
  if (!response.ok) {
6505
3963
  throw new Error(`Hyper Loop health ${response.status}`);
@@ -6527,13 +3985,17 @@ document.addEventListener('DOMContentLoaded', () => {
6527
3985
  const endpoint = process.env.VIGTHORIA_HYPERLOOP_EXECUTE_URL || `${configuredApiUrl}/api/hyperloop/execute`;
6528
3986
  const modulesEndpoint = process.env.VIGTHORIA_HYPERLOOP_MODULES_URL || `${configuredApiUrl}/api/hyperloop/modules`;
6529
3987
  const token = this.getAccessToken();
3988
+ const hyperLoopServiceKey = process.env.VIGTHORIA_HYPERLOOP_SERVICE_KEY || process.env.HYPERLOOP_SERVICE_KEY || '';
3989
+ const hyperLoopHeaders = {
3990
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
3991
+ ...(hyperLoopServiceKey ? { 'X-Service-Key': hyperLoopServiceKey } : {}),
3992
+ };
6530
3993
  const projectPath = this.resolveAgentTargetPath(context);
3994
+ const projectAlias = `vigthoria://workspace/${encodeURIComponent(path.basename(projectPath || 'workspace'))}`;
6531
3995
  try {
6532
3996
  const modulesResponse = await fetchWithServiceTimeout(modulesEndpoint, {
6533
3997
  method: 'GET',
6534
- headers: {
6535
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
6536
- },
3998
+ headers: hyperLoopHeaders,
6537
3999
  });
6538
4000
  if (!modulesResponse.ok) {
6539
4001
  throw new Error(`Repo memory modules ${modulesResponse.status}`);
@@ -6545,27 +4007,26 @@ document.addEventListener('DOMContentLoaded', () => {
6545
4007
  try {
6546
4008
  const probeResponse = await fetchWithServiceTimeout(endpoint, {
6547
4009
  method: 'POST',
6548
- headers: {
6549
- 'Content-Type': 'application/json',
6550
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
6551
- },
4010
+ headers: { 'Content-Type': 'application/json', ...hyperLoopHeaders },
6552
4011
  body: JSON.stringify({
6553
4012
  module: 'repo_context_compactor',
6554
4013
  payload: {
6555
- path: projectPath,
4014
+ path: projectAlias,
6556
4015
  request_focus: 'Capability truth status probe',
6557
4016
  max_files: 20,
6558
4017
  max_chars: 800,
6559
4018
  },
6560
4019
  context: {
6561
- project_path: projectPath,
4020
+ project_path: projectAlias,
6562
4021
  source: 'vigthoria-cli-capability-truth',
6563
4022
  },
6564
4023
  }),
6565
4024
  });
6566
4025
  if (probeResponse.ok) {
6567
4026
  const probeData = await probeResponse.json();
6568
- compactContextLength = String(probeData?.result?.compact_context || '').length;
4027
+ compactContextLength = String(probeData?.result?.compact_context
4028
+ || probeData?.result?.result?.compact_context
4029
+ || '').length;
6569
4030
  }
6570
4031
  }
6571
4032
  catch {
@@ -6621,19 +4082,38 @@ document.addEventListener('DOMContentLoaded', () => {
6621
4082
  message: 'V3 service is reachable again — retrying the workflow once.',
6622
4083
  };
6623
4084
  }
6624
- async getDevtoolsBridgeStatus() {
4085
+ async probeDevtoolsBridgeStatus() {
6625
4086
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
6626
4087
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
6627
4088
  const endpoint = `ws://${host}:${port}/ws`;
6628
4089
  const healthEndpoint = `http://${host}:${port}/health`;
4090
+ // The HTTP policy protects fetch, but the legacy compatibility probe below
4091
+ // opens a raw TCP socket. Validate once before either transport so offline
4092
+ // mode and explicit-loopback consent cannot be bypassed by the fallback.
4093
+ try {
4094
+ assertTrustedEndpoint(healthEndpoint, { audience: 'bridge' });
4095
+ }
4096
+ catch (error) {
4097
+ return {
4098
+ name: 'DevTools Bridge',
4099
+ endpoint,
4100
+ ok: false,
4101
+ error: error instanceof Error ? error.message : String(error),
4102
+ };
4103
+ }
6629
4104
  try {
6630
4105
  const controller = new AbortController();
6631
4106
  const timer = setTimeout(() => controller.abort(), 1500);
6632
- const response = await fetch(healthEndpoint, {
6633
- signal: controller.signal,
6634
- headers: { Accept: 'application/json' },
6635
- });
6636
- clearTimeout(timer);
4107
+ let response;
4108
+ try {
4109
+ response = await this.fetch(healthEndpoint, {
4110
+ signal: controller.signal,
4111
+ headers: { Accept: 'application/json' },
4112
+ });
4113
+ }
4114
+ finally {
4115
+ clearTimeout(timer);
4116
+ }
6637
4117
  if (response.ok) {
6638
4118
  return {
6639
4119
  name: 'DevTools Bridge',
@@ -6681,40 +4161,14 @@ document.addEventListener('DOMContentLoaded', () => {
6681
4161
  });
6682
4162
  });
6683
4163
  }
4164
+ async getDevtoolsBridgeStatus() {
4165
+ return this.probeDevtoolsBridgeStatus();
4166
+ }
6684
4167
  async getCapabilityTruthStatus(context = {}) {
6685
- // Wrap each probe with its own 6 s timeout so they always resolve
6686
- // before the outer 8 s race in auth.ts, producing real error messages
6687
- // (ECONNREFUSED, 404, etc.) instead of the generic "Timed out (8s)".
6688
- const withTimeout = (p, name) => Promise.race([
6689
- p,
6690
- new Promise(resolve => setTimeout(() => resolve({ name, endpoint: '', ok: false, error: 'Service not reachable (6 s timeout)' }), 6000)),
6691
- ]);
6692
- const [v3Agent, hyperLoop, repoMemory, devtoolsBridge] = await Promise.all([
6693
- withTimeout(this.getV3AgentHealth(), 'V3 Agent'),
6694
- withTimeout(this.getHyperLoopHealth(), 'Hyper Loop'),
6695
- withTimeout(this.getRepoMemoryHealth(context), 'Repo Memory'),
6696
- withTimeout(this.getDevtoolsBridgeStatus(), 'DevTools Bridge'),
6697
- ]);
6698
- return {
6699
- overallOk: v3Agent.ok && (hyperLoop.ok || repoMemory.ok),
6700
- v3Agent,
6701
- hyperLoop,
6702
- repoMemory,
6703
- devtoolsBridge,
6704
- };
4168
+ return this.capabilityHealthService.getCapabilityTruth(context);
6705
4169
  }
6706
4170
  async getHealthStatus() {
6707
- const [coder, models, selfHosted] = await Promise.all([
6708
- this.getCoderHealth(),
6709
- this.getModelsHealth(),
6710
- this.getSelfHostedHealth(),
6711
- ]);
6712
- return {
6713
- overallOk: coder.ok && models.ok,
6714
- coder,
6715
- models,
6716
- selfHosted,
6717
- };
4171
+ return this.capabilityHealthService.getHealth();
6718
4172
  }
6719
4173
  // Health check
6720
4174
  async healthCheck() {