vigthoria-cli 1.13.20 → 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 +63 -128
  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 +3 -42
  12. package/dist/commands/chat.js +378 -1123
  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 +23 -29
  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 -1589
  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 +44 -266
  71. package/dist/utils/api.js +647 -3180
  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 +220 -94
  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 +5 -6
  106. package/dist/utils/fastAgentRouter.js +17 -25
  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 +17 -3
  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 +85 -142
  167. package/install.sh +75 -80
  168. package/package.json +43 -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 -174
  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,62 +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 endpoint is an operator routing decision. External installs
677
- // have none and continue to use the public Coder gateway first.
678
- const urls = (preferLocal || localTestMode || explicitV3EndpointConfigured) && localCandidates.length > 0
679
- ? [...localCandidates, ...normalizedRemote]
680
- : [...normalizedRemote, ...localCandidates];
681
- return [...new Set(urls)];
696
+ return this.v3AgentClient.getBaseUrls(preferLocal);
682
697
  }
683
698
  getV3AgentRunUrl(baseUrl) {
684
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
685
- return `${baseUrl}/api/agent/run`;
686
- }
687
- return `${baseUrl}/api/v3-agent/run`;
699
+ return this.v3AgentClient.runUrl(baseUrl);
688
700
  }
689
701
  getV3AgentContinueUrl(baseUrl) {
690
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
691
- return `${baseUrl}/api/agent/continue`;
692
- }
693
- return `${baseUrl}/api/v3-agent/continue`;
702
+ return this.v3AgentClient.continueUrl(baseUrl);
694
703
  }
695
704
  getV3AgentBackgroundUrl(baseUrl, suffix = '') {
696
- const cleanSuffix = suffix ? `/${suffix.replace(/^\/+/, '')}` : '';
697
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
698
- return `${baseUrl}/api/agent/background${cleanSuffix}`;
699
- }
700
- return `${baseUrl}/api/v3-agent/background${cleanSuffix}`;
705
+ return this.v3AgentClient.backgroundUrl(baseUrl, suffix);
701
706
  }
702
707
  getV3AgentApprovalUrl(baseUrl) {
703
- if (this.isDirectV3AgentBaseUrl(baseUrl)) {
704
- return `${baseUrl}/api/agent/approval`;
705
- }
706
- return `${baseUrl}/api/v3-agent/approval`;
708
+ return this.v3AgentClient.approvalUrl(baseUrl);
707
709
  }
708
710
  getOperatorBaseUrls() {
709
711
  const configuredModelsApiUrl = String(this.config.get('modelsApiUrl') || 'https://api.vigthoria.io').replace(/\/$/, '');
@@ -716,9 +718,6 @@ export class APIClient {
716
718
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
717
719
  return [...new Set(urls)];
718
720
  }
719
- getOperatorStreamUrl(baseUrl) {
720
- return `${baseUrl}/api/operator/stream`;
721
- }
722
721
  getMcpBaseUrls() {
723
722
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
724
723
  const localTestMode = isLocalTestfarmMode();
@@ -764,22 +763,6 @@ export class APIClient {
764
763
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
765
764
  return [...new Set(urls)];
766
765
  }
767
- isFrontendTask(message = '', context = {}) {
768
- // Never treat analysis-only tasks as frontend tasks — preview gate
769
- // should not fire for read-only inspection prompts.
770
- if (this.isAnalysisOnlyTask(message, context)) {
771
- return false;
772
- }
773
- const prompt = String(message || '');
774
- const expectedFiles = this.extractExpectedWorkspaceFiles(message, context);
775
- const hasHtmlEntry = expectedFiles.some((filePath) => /\.html?$/i.test(filePath));
776
- return /(premium|polished|landing|site|page|dashboard|saas|frontend|ui|pricing|showcase|hero|responsive)/i.test(prompt)
777
- || hasHtmlEntry;
778
- }
779
- /**
780
- * Returns true when the prompt describes a read-only / analysis task.
781
- * Used to suppress preview gate and other write-oriented side effects.
782
- */
783
766
  isAnalysisOnlyTask(message = '', context = {}) {
784
767
  const prompt = String(message || '').toLowerCase();
785
768
  const taskType = String(context.agentTaskType || '').toLowerCase();
@@ -789,797 +772,51 @@ export class APIClient {
789
772
  return true;
790
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());
791
774
  }
775
+ runTemplateServicePreviewGate(message = '', context = {}) {
776
+ return this.frontendPreviewService.runTemplateServicePreviewGate(message, context);
777
+ }
792
778
  normalizeWorkspaceRelativePath(filePath) {
793
- return String(filePath || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
779
+ return this.frontendPreviewService.normalizeWorkspaceRelativePath(filePath);
794
780
  }
795
781
  collectAgentStateSyncPaths(rootPath) {
796
- const paths = [];
797
- const progressPath = path.join(rootPath, '.vigthoria', 'agent-state', 'progress.json');
798
- if (!fs.existsSync(progressPath)) {
799
- return paths;
800
- }
801
- paths.push('.vigthoria/agent-state/progress.json');
802
- const tasksDir = path.join(rootPath, '.vigthoria', 'agent-state', 'tasks');
803
- if (!fs.existsSync(tasksDir)) {
804
- return paths;
805
- }
806
- try {
807
- for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
808
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
809
- continue;
810
- }
811
- paths.push(`.vigthoria/agent-state/tasks/${entry.name}`);
812
- }
813
- }
814
- catch {
815
- // Ignore unreadable task snapshots.
816
- }
817
- return paths;
818
- }
819
- listFrontendWorkspaceFiles(rootPath) {
820
- const results = [];
821
- const stack = [rootPath];
822
- while (stack.length > 0) {
823
- const current = stack.pop();
824
- if (!current) {
825
- continue;
826
- }
827
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
828
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.vigthoria') {
829
- continue;
830
- }
831
- const fullPath = path.join(current, entry.name);
832
- if (entry.isDirectory()) {
833
- stack.push(fullPath);
834
- continue;
835
- }
836
- if (!entry.isFile()) {
837
- continue;
838
- }
839
- const relativePath = this.normalizeWorkspaceRelativePath(path.relative(rootPath, fullPath));
840
- if (relativePath) {
841
- results.push(relativePath);
842
- }
843
- }
844
- }
845
- results.sort((left, right) => left.localeCompare(right));
846
- return results;
847
- }
848
- chooseFrontendPreviewEntry(rootPath, htmlPaths, expectedFiles = []) {
849
- const normalizedExpected = expectedFiles
850
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
851
- .filter((filePath) => /\.html?$/i.test(filePath));
852
- for (const expectedPath of normalizedExpected) {
853
- if (htmlPaths.includes(expectedPath)) {
854
- return expectedPath;
855
- }
856
- }
857
- const scored = htmlPaths.map((relativePath) => {
858
- const baseName = path.posix.basename(relativePath).toLowerCase();
859
- const depth = relativePath.split('/').length - 1;
860
- let score = 0;
861
- const normalizedPath = relativePath.toLowerCase();
862
- const topLevelDir = normalizedPath.split('/')[0];
863
- if (['dist', 'build', 'out'].includes(topLevelDir)) {
864
- score += 320;
865
- }
866
- else if (topLevelDir === 'public') {
867
- score += 180;
868
- }
869
- if (normalizedPath === 'index.html') {
870
- score += 200;
871
- }
872
- if (baseName === 'index.html') {
873
- score += 120;
874
- }
875
- if (/(landing|home|main|dashboard|app)/i.test(baseName)) {
876
- score += 60;
877
- }
878
- if (depth === 0) {
879
- score += 40;
880
- }
881
- return { relativePath, score, depth };
882
- }).sort((left, right) => right.score - left.score || left.depth - right.depth || left.relativePath.localeCompare(right.relativePath));
883
- return scored[0]?.relativePath || null;
782
+ return this.frontendPreviewService.collectAgentStateSyncPaths(rootPath);
884
783
  }
885
784
  extractLinkedFrontendAssets(html, entryPath) {
886
- const css = new Set();
887
- const js = new Set();
888
- const assets = new Set();
889
- const entryDir = path.posix.dirname(this.normalizeWorkspaceRelativePath(entryPath) || '.');
890
- const normalizeAsset = (assetPath) => {
891
- const clean = String(assetPath || '').trim();
892
- if (!clean || clean.startsWith('http://') || clean.startsWith('https://') || clean.startsWith('//') || clean.startsWith('data:') || clean.startsWith('#')) {
893
- return null;
894
- }
895
- const withoutQuery = clean.split('?')[0].split('#')[0];
896
- if (!withoutQuery) {
897
- return null;
898
- }
899
- if (withoutQuery.startsWith('/')) {
900
- return this.normalizeWorkspaceRelativePath(path.posix.normalize(path.posix.join(entryDir, withoutQuery.slice(1))));
901
- }
902
- return this.normalizeWorkspaceRelativePath(path.posix.normalize(path.posix.join(entryDir, withoutQuery)));
903
- };
904
- const cssPattern = /<link[^>]+href=["']([^"']+\.css(?:[?#][^"']*)?)["'][^>]*>/gi;
905
- const jsPattern = /<script[^>]+src=["']([^"']+\.(?:js|mjs|cjs)(?:[?#][^"']*)?)["'][^>]*>/gi;
906
- let match;
907
- while ((match = cssPattern.exec(String(html || ''))) !== null) {
908
- const resolved = normalizeAsset(match[1]);
909
- if (resolved) {
910
- css.add(resolved);
911
- }
912
- }
913
- while ((match = jsPattern.exec(String(html || ''))) !== null) {
914
- const resolved = normalizeAsset(match[1]);
915
- if (resolved) {
916
- js.add(resolved);
917
- }
918
- }
919
- const assetPattern = /<(?:link|img|source|video|audio)\b[^>]+(?:href|src)=["']([^"']+)["'][^>]*>/gi;
920
- while ((match = assetPattern.exec(String(html || ''))) !== null) {
921
- const resolved = normalizeAsset(match[1]);
922
- if (resolved)
923
- assets.add(resolved);
924
- }
925
- return {
926
- css: Array.from(css),
927
- js: Array.from(js),
928
- assets: Array.from(assets),
929
- };
930
- }
931
- async buildFrontendForPreview(rootPath) {
932
- const packagePath = path.join(rootPath, 'package.json');
933
- if (!fs.existsSync(packagePath)) {
934
- return { attempted: false, succeeded: false };
935
- }
936
- try {
937
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
938
- if (!packageJson.scripts?.build) {
939
- return { attempted: false, succeeded: false };
940
- }
941
- }
942
- catch (error) {
943
- return { attempted: false, succeeded: false, error: `Invalid package.json: ${error?.message || String(error)}` };
944
- }
945
- const npmCommand = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
946
- const npmArgs = process.platform === 'win32'
947
- ? ['/d', '/s', '/c', 'npm run build']
948
- : ['run', 'build'];
949
- return await new Promise((resolve) => {
950
- execFile(npmCommand, npmArgs, { cwd: rootPath, timeout: 150_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }, (error) => {
951
- if (error) {
952
- resolve({ attempted: true, succeeded: false, error: sanitizeUserFacingErrorText(error.message).slice(0, 300) });
953
- return;
954
- }
955
- resolve({ attempted: true, succeeded: true });
956
- });
957
- });
958
- }
959
- gatherFrontendPreviewArtifacts(message = '', context = {}) {
960
- const rootPath = this.resolveAgentTargetPath(context);
961
- if (!rootPath || !fs.existsSync(rootPath)) {
962
- return { error: 'Frontend preview gate requires a readable project workspace.' };
963
- }
964
- const workspaceFiles = this.listFrontendWorkspaceFiles(rootPath);
965
- const expectedFiles = this.extractExpectedWorkspaceFiles(message, context);
966
- const htmlPaths = workspaceFiles.filter((filePath) => /\.html?$/i.test(filePath));
967
- if (htmlPaths.length === 0) {
968
- return { error: 'Frontend preview gate requires at least one HTML entry file in the workspace.' };
969
- }
970
- const htmlPath = this.chooseFrontendPreviewEntry(rootPath, htmlPaths, expectedFiles);
971
- if (!htmlPath) {
972
- return { error: 'Frontend preview gate could not determine a primary HTML entry file.' };
973
- }
974
- const html = fs.readFileSync(path.join(rootPath, htmlPath), 'utf8');
975
- const linkedAssets = this.extractLinkedFrontendAssets(html, htmlPath);
976
- const cssFiles = workspaceFiles.filter((filePath) => /\.css$/i.test(filePath));
977
- const jsFiles = workspaceFiles.filter((filePath) => /\.(?:js|mjs|cjs)$/i.test(filePath));
978
- const expectedCss = expectedFiles
979
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
980
- .filter((filePath) => /\.css$/i.test(filePath));
981
- const expectedJs = expectedFiles
982
- .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
983
- .filter((filePath) => /\.(?:js|mjs|cjs)$/i.test(filePath));
984
- const chooseExisting = (candidates, fallback) => {
985
- const selected = new Set();
986
- for (const candidate of [...candidates, ...fallback]) {
987
- const normalized = this.normalizeWorkspaceRelativePath(candidate);
988
- if (!normalized || selected.has(normalized)) {
989
- continue;
990
- }
991
- if (!fs.existsSync(path.join(rootPath, normalized))) {
992
- continue;
993
- }
994
- selected.add(normalized);
995
- }
996
- return Array.from(selected);
997
- };
998
- const selectedCssFiles = chooseExisting(linkedAssets.css, [...expectedCss, ...cssFiles]).slice(0, 12);
999
- const selectedJsFiles = chooseExisting(linkedAssets.js, [...expectedJs, ...jsFiles]).slice(0, 12);
1000
- const css = selectedCssFiles.map((filePath) => fs.readFileSync(path.join(rootPath, filePath), 'utf8')).join('\n\n');
1001
- const js = selectedJsFiles.map((filePath) => fs.readFileSync(path.join(rootPath, filePath), 'utf8')).join('\n\n');
1002
- return { htmlPath, html, css, js, cssPaths: selectedCssFiles, jsPaths: selectedJsFiles };
1003
- }
1004
- async captureFrontendPreviewScreenshot(entryAbsolutePath, screenshotPath) {
1005
- try {
1006
- const puppeteerModule = await import('puppeteer').catch(() => null);
1007
- const puppeteer = puppeteerModule?.default || puppeteerModule;
1008
- if (!puppeteer || typeof puppeteer.launch !== 'function') {
1009
- return { captured: false, error: 'puppeteer-unavailable' };
1010
- }
1011
- const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
1012
- try {
1013
- const page = await browser.newPage();
1014
- await page.setViewport({ width: 1440, height: 960, deviceScaleFactor: 1 });
1015
- await page.goto(`file://${entryAbsolutePath}`, { waitUntil: 'networkidle0', timeout: 20000 });
1016
- await page.screenshot({ path: screenshotPath, fullPage: true });
1017
- }
1018
- finally {
1019
- await browser.close();
1020
- }
1021
- return { captured: true };
1022
- }
1023
- catch (error) {
1024
- return { captured: false, error: error?.message || String(error) };
1025
- }
785
+ return this.frontendPreviewService.extractLinkedFrontendAssets(html, entryPath);
1026
786
  }
1027
- evaluateFrontendVisualProof(summary = {}, artifacts = {}) {
1028
- const reasons = [];
1029
- const cssPaths = Array.isArray(artifacts.cssPaths) ? artifacts.cssPaths : [];
1030
- const html = String(artifacts.html || '');
1031
- const css = String(artifacts.css || '');
1032
- const js = String(artifacts.js || '');
1033
- if (!summary.hasViewportMeta) {
1034
- reasons.push('missing viewport metadata');
1035
- }
1036
- const hasStyleEvidence = cssPaths.length > 0 || /<style[\s>]/i.test(html) || css.trim().length > 0;
1037
- if (!hasStyleEvidence) {
1038
- reasons.push('missing stylesheet or inline style evidence');
1039
- }
1040
- const hasInteractionOrResponsiveEvidence = Boolean(summary.hasResponsiveSignals) || Boolean(summary.hasInteractiveSignals) || /<script[\s>]/i.test(html) || js.trim().length > 0;
1041
- if (!hasInteractionOrResponsiveEvidence) {
1042
- reasons.push('missing responsive or interactive evidence');
1043
- }
1044
- return {
1045
- ok: reasons.length === 0,
1046
- reasons,
1047
- };
787
+ listVigFlowTemplates(options = {}) {
788
+ return this.vigFlowClient.listTemplates(options);
1048
789
  }
1049
- async persistFrontendPreviewArtifacts(previewGate, rootPath, context = {}) {
1050
- if (!previewGate.required || !previewGate.entryPath) {
1051
- return previewGate;
1052
- }
1053
- try {
1054
- const artifactsDir = path.join(rootPath, '.vigthoria', 'proof', 'preview');
1055
- fs.mkdirSync(artifactsDir, { recursive: true });
1056
- const contextId = String(context.contextId || 'preview').trim() || 'preview';
1057
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1058
- const baseName = `${timestamp}-${contextId}`;
1059
- const manifestPath = path.join(artifactsDir, `${baseName}.json`);
1060
- const screenshotPath = path.join(artifactsDir, `${baseName}.png`);
1061
- const entryAbsolutePath = path.join(rootPath, previewGate.entryPath);
1062
- const previewFileUrl = `file://${entryAbsolutePath}`;
1063
- const screenshot = await this.captureFrontendPreviewScreenshot(entryAbsolutePath, screenshotPath);
1064
- const manifest = {
1065
- schemaVersion: 1,
1066
- createdAt: new Date().toISOString(),
1067
- contextId: context.contextId || null,
1068
- workspaceRoot: rootPath,
1069
- entryPath: previewGate.entryPath,
1070
- previewFileUrl,
1071
- previewCommand: `vigthoria preview -p ${rootPath}`,
1072
- previewGate,
1073
- validationArtifacts: {
1074
- summary: previewGate.summary || {},
1075
- modes: previewGate.modes || {},
1076
- backendUrl: previewGate.backendUrl || null,
1077
- processingTimeMs: previewGate.processingTimeMs || null,
1078
- assetPaths: previewGate.assetPaths || { css: [], js: [] },
1079
- },
1080
- screenshot: {
1081
- path: screenshot.captured ? screenshotPath : null,
1082
- captured: screenshot.captured,
1083
- error: screenshot.error || null,
1084
- },
1085
- };
1086
- fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
1087
- return {
1088
- ...previewGate,
1089
- artifacts: {
1090
- manifestPath,
1091
- screenshotPath: screenshot.captured ? screenshotPath : undefined,
1092
- previewFileUrl,
1093
- screenshotCaptured: screenshot.captured,
1094
- screenshotError: screenshot.error,
1095
- },
1096
- };
1097
- }
1098
- catch (error) {
1099
- return {
1100
- ...previewGate,
1101
- artifacts: {
1102
- ...(previewGate.artifacts || {}),
1103
- screenshotCaptured: false,
1104
- screenshotError: error?.message || String(error),
1105
- },
1106
- };
1107
- }
790
+ listVigFlowWorkflows() {
791
+ return this.vigFlowClient.listWorkflows();
1108
792
  }
1109
- async runTemplateServicePreviewGate(message = '', context = {}) {
1110
- if (!this.isFrontendTask(message, context)) {
1111
- return { required: false, passed: true };
1112
- }
1113
- const rootPath = this.resolveAgentTargetPath(context);
1114
- let artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1115
- let buildResult = null;
1116
- if (rootPath && artifacts.error?.includes('at least one HTML entry file')) {
1117
- buildResult = await this.buildFrontendForPreview(rootPath);
1118
- if (buildResult.attempted) {
1119
- artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1120
- }
1121
- }
1122
- if (artifacts.error || !artifacts.htmlPath || typeof artifacts.html !== 'string') {
1123
- const buildDetail = buildResult?.attempted
1124
- ? ` Build retry ${buildResult.succeeded ? 'completed but produced no HTML entry' : `failed: ${buildResult.error || 'unknown build error'}`}.`
1125
- : '';
1126
- return {
1127
- required: true,
1128
- passed: false,
1129
- error: `${artifacts.error || 'Frontend preview gate could not collect frontend artifacts.'}${buildDetail}`,
1130
- };
1131
- }
1132
- // Cap artifact sizes to prevent 413 Payload Too Large
1133
- const PREVIEW_MAX_ARTIFACT_BYTES = 250 * 1024;
1134
- const html = artifacts.html.slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1135
- const css = (artifacts.css || '').slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1136
- const js = (artifacts.js || '').slice(0, PREVIEW_MAX_ARTIFACT_BYTES);
1137
- const errors = [];
1138
- for (const baseUrl of this.getTemplateServiceBaseUrls()) {
1139
- try {
1140
- const proofPayload = JSON.stringify({
1141
- vision: String(context.rawPrompt || message || '').slice(0, 2000),
1142
- html,
1143
- css,
1144
- js,
1145
- entryPath: artifacts.htmlPath,
1146
- workspaceName: path.basename(rootPath),
1147
- });
1148
- // Skip preview proof if payload is still too large (> 4 MB)
1149
- if (Buffer.byteLength(proofPayload, 'utf8') > 4 * 1024 * 1024) {
1150
- return {
1151
- required: true,
1152
- passed: true,
1153
- error: 'Preview proof skipped: payload exceeds size limit.',
1154
- };
1155
- }
1156
- const internalServiceKey = process.env.VIGTHORIA_TEMPLATE_SERVICE_KEY
1157
- || process.env.V3_SERVICE_KEY;
1158
- const authToken = this.getAccessToken();
1159
- const response = await fetch(`${baseUrl}/preview-proof`, {
1160
- method: 'POST',
1161
- headers: {
1162
- 'Content-Type': 'application/json',
1163
- Accept: 'application/json',
1164
- ...(authToken ? {
1165
- Authorization: `Bearer ${authToken}`,
1166
- Cookie: `vigthoria-auth-token=${authToken}`,
1167
- } : {}),
1168
- ...(internalServiceKey ? { 'x-vigthoria-service-key': internalServiceKey } : {}),
1169
- },
1170
- body: proofPayload,
1171
- });
1172
- if (!response.ok) {
1173
- const errorText = await response.text().catch(() => '');
1174
- throw new Error(`Template preview proof ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
1175
- }
1176
- const payload = await response.json();
1177
- const modes = payload?.modes || {};
1178
- const summary = payload?.summary || {};
1179
- const visualProof = this.evaluateFrontendVisualProof(summary, artifacts);
1180
- const passed = payload?.success === true
1181
- && modes?.design?.ready === true
1182
- && modes?.live?.ready === true
1183
- && modes?.production?.ready === true
1184
- && visualProof.ok;
1185
- const previewGate = {
1186
- required: true,
1187
- passed,
1188
- backendUrl: baseUrl,
1189
- entryPath: artifacts.htmlPath,
1190
- assetPaths: {
1191
- css: artifacts.cssPaths || [],
1192
- js: artifacts.jsPaths || [],
1193
- },
1194
- modes,
1195
- summary,
1196
- processingTimeMs: Number(payload?.processing_time || 0) || undefined,
1197
- 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(', ')}.`),
1198
- };
1199
- return await this.persistFrontendPreviewArtifacts(previewGate, rootPath, context);
1200
- }
1201
- catch (error) {
1202
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
1203
- }
1204
- }
1205
- return await this.persistFrontendPreviewArtifacts({
1206
- required: true,
1207
- passed: false,
1208
- entryPath: artifacts.htmlPath,
1209
- assetPaths: {
1210
- css: artifacts.cssPaths || [],
1211
- js: artifacts.jsPaths || [],
1212
- },
1213
- error: errors.join(' | ') || 'No Template Service preview endpoint was reachable.',
1214
- }, rootPath, context);
793
+ resolveVigFlowWorkflow(selector) {
794
+ return this.vigFlowClient.resolveWorkflow(selector);
1215
795
  }
1216
- getMcpContextCreateUrl(baseUrl) {
1217
- return `${baseUrl}/mcp/context/create`;
796
+ useVigFlowTemplate(templateId, options = {}) {
797
+ return this.vigFlowClient.useTemplate(templateId, options);
1218
798
  }
1219
- getMcpContextUrl(baseUrl, contextId) {
1220
- return `${baseUrl}/mcp/context/${encodeURIComponent(contextId)}`;
799
+ runVigFlowWorkflow(workflowId, options = {}) {
800
+ return this.vigFlowClient.runWorkflow(workflowId, options);
1221
801
  }
1222
- async getMcpHeaders() {
1223
- const headers = {
1224
- 'Content-Type': 'application/json',
1225
- Accept: 'application/json',
1226
- };
1227
- const authToken = this.getAccessToken();
1228
- if (authToken) {
1229
- headers.Authorization = `Bearer ${authToken}`;
1230
- headers.Cookie = `vigthoria-auth-token=${authToken}`;
1231
- headers['X-Vigthoria-Token'] = authToken;
1232
- headers['X-Auth-Token'] = authToken;
1233
- }
1234
- return headers;
802
+ getVigFlowExecutionStatus(executionId) {
803
+ return this.vigFlowClient.executionStatus(executionId);
1235
804
  }
1236
805
  async getV3AgentHeaders() {
1237
- const headers = {
1238
- 'Content-Type': 'application/json',
1239
- Accept: 'text/event-stream',
1240
- };
1241
- const authToken = this.getAccessToken();
1242
- if (authToken) {
1243
- headers.Authorization = `Bearer ${authToken}`;
1244
- headers.Cookie = `vigthoria-auth-token=${authToken}`;
1245
- }
1246
- const serviceKey = process.env.VIGTHORIA_V3_SERVICE_KEY
1247
- || process.env.V3_SERVICE_KEY
1248
- || process.env.HYPERLOOP_SERVICE_KEY
1249
- || this.config.get('v3ServiceKey');
1250
- if (serviceKey) {
1251
- headers['X-Service-Key'] = serviceKey;
1252
- }
1253
- return headers;
806
+ return this.v3AgentClient.headers();
1254
807
  }
1255
- /**
1256
- * Ensure the V3 service key is available in config.
1257
- * If not already set via env or stored config, fetches it from the Coder API
1258
- * using the current user's auth token and caches it for this session and beyond.
1259
- */
1260
808
  async ensureV3ServiceKey() {
1261
- const existing = process.env.VIGTHORIA_V3_SERVICE_KEY
1262
- || process.env.V3_SERVICE_KEY
1263
- || process.env.HYPERLOOP_SERVICE_KEY
1264
- || this.config.get('v3ServiceKey');
1265
- if (existing)
1266
- return;
1267
- try {
1268
- const token = this.getAccessToken();
1269
- if (!token)
1270
- return;
1271
- const response = await this.client.get('/api/cli/v3-service-key', {
1272
- headers: { Authorization: `Bearer ${token}` },
1273
- timeout: 6000,
1274
- });
1275
- const key = response.data?.v3ServiceKey;
1276
- if (key) {
1277
- this.config.set('v3ServiceKey', key);
1278
- this.logger.debug('V3 service key retrieved and cached from Coder API.');
1279
- }
1280
- }
1281
- catch {
1282
- // Non-fatal: fall through; the health check will surface the auth error.
1283
- }
809
+ return this.v3AgentClient.ensureServiceKey();
1284
810
  }
1285
811
  async runV3HealthCheck() {
1286
- const endpoints = this.getV3AgentBaseUrls(false);
1287
- const headers = await this.getV3AgentHeaders();
1288
- const failures = [];
1289
- for (const baseUrl of endpoints) {
1290
- const runUrl = this.getV3AgentRunUrl(baseUrl);
1291
- // Use the lightweight GET /health endpoint — no LLM invocation, responds instantly.
1292
- // For local 8030 the path is /health directly; for remote it's /api/v3-agent/health via proxy.
1293
- const healthUrl = this.isDirectV3AgentBaseUrl(baseUrl)
1294
- ? `${baseUrl}/health`
1295
- : `${baseUrl}/api/v3-agent/health`;
1296
- for (let attempt = 1; attempt <= 3; attempt += 1) {
1297
- try {
1298
- const response = await fetch(healthUrl, {
1299
- method: 'GET',
1300
- headers,
1301
- signal: AbortSignal.timeout(8000),
1302
- });
1303
- if (response.status === 401 || response.status === 403) {
1304
- return {
1305
- healthy: false,
1306
- endpoint: runUrl,
1307
- error: 'V3 service rejected authentication. Please re-login to refresh your token.',
1308
- };
1309
- }
1310
- // 200 OK or 405 Method Not Allowed both mean V3 is reachable.
1311
- if (response.ok || response.status === 405) {
1312
- return { healthy: true, endpoint: runUrl };
1313
- }
1314
- failures.push(`${healthUrl} attempt ${attempt}: HTTP ${response.status}`);
1315
- }
1316
- catch (error) {
1317
- const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 160);
1318
- failures.push(`${healthUrl} attempt ${attempt}: ${reason || 'network error'}`);
1319
- }
1320
- if (attempt < 3) {
1321
- await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
1322
- }
1323
- }
1324
- }
1325
- return {
1326
- healthy: false,
1327
- endpoint: endpoints[0] ? this.getV3AgentRunUrl(endpoints[0]) : 'unknown',
1328
- error: `V3 service is not reachable during startup preflight. Checked ${endpoints.length} endpoint(s). ${failures.slice(-3).join(' | ')}`,
1329
- };
812
+ return this.v3AgentClient.health();
1330
813
  }
1331
- async runV3AgentAuthPreflight(baseUrl, body, executionContext) {
1332
- const endpoint = this.getV3AgentRunUrl(baseUrl);
1333
- const healthEndpoint = this.isDirectV3AgentBaseUrl(baseUrl)
1334
- ? `${baseUrl}/health`
1335
- : `${baseUrl}/api/v3-agent/health`;
1336
- const headers = await this.getV3AgentHeaders();
1337
- headers['X-Vigthoria-Preflight'] = '1';
1338
- if (executionContext.mcpContextId) {
1339
- headers['X-MCP-Context-Id'] = String(executionContext.mcpContextId);
1340
- }
1341
- const controller = new AbortController();
1342
- const timeoutId = setTimeout(() => controller.abort(), 12000);
1343
- try {
1344
- const response = await fetch(healthEndpoint, {
1345
- method: 'GET',
1346
- headers,
1347
- signal: controller.signal,
1348
- });
1349
- if (response.status === 401 || response.status === 403) {
1350
- const errorText = await response.text().catch(() => '');
1351
- return {
1352
- ok: false,
1353
- status: response.status,
1354
- endpoint: healthEndpoint,
1355
- reason: sanitizeUserFacingErrorText(errorText).slice(0, 200) || 'Unauthorized',
1356
- };
1357
- }
1358
- // Any non-auth response means token acceptance passed at the V3 service layer.
1359
- return { ok: true, status: response.status, endpoint: healthEndpoint };
1360
- }
1361
- catch (error) {
1362
- const reason = sanitizeUserFacingErrorText(error?.message || String(error)).slice(0, 200);
1363
- // Preflight should not block agent execution on transient timeout/abort network conditions.
1364
- if (/aborted|timeout|timed out|network/i.test(reason)) {
1365
- return {
1366
- ok: true,
1367
- status: 0,
1368
- endpoint: healthEndpoint,
1369
- reason,
1370
- };
1371
- }
1372
- return {
1373
- ok: false,
1374
- status: 0,
1375
- endpoint: healthEndpoint,
1376
- reason,
1377
- };
1378
- }
1379
- finally {
1380
- clearTimeout(timeoutId);
1381
- }
814
+ runV3AgentAuthPreflight(baseUrl, executionContext) {
815
+ return this.v3AgentClient.preflight(baseUrl, executionContext);
1382
816
  }
1383
- async executeV3AgentRunRequest(baseUrl, body, executionContext, signal) {
1384
- const makeRequest = async () => {
1385
- const headers = await this.getV3AgentHeaders();
1386
- if (executionContext.mcpContextId) {
1387
- headers['X-MCP-Context-Id'] = String(executionContext.mcpContextId);
1388
- }
1389
- return fetch(this.getV3AgentRunUrl(baseUrl), {
1390
- method: 'POST',
1391
- headers,
1392
- body: JSON.stringify(body),
1393
- signal,
1394
- });
1395
- };
1396
- let response = await makeRequest();
1397
- if (response.status !== 401) {
1398
- return response;
1399
- }
1400
- const refreshed = await this.refreshToken();
1401
- if (!refreshed) {
1402
- return response;
1403
- }
1404
- response = await makeRequest();
1405
- return response;
817
+ executeV3AgentRunRequest(baseUrl, body, executionContext, signal) {
818
+ return this.v3AgentClient.run(baseUrl, body, executionContext, signal);
1406
819
  }
1407
- async getVigFlowAccessToken(baseUrl) {
1408
- const cachedToken = this.vigFlowTokens.get(baseUrl);
1409
- if (cachedToken) {
1410
- return cachedToken;
1411
- }
1412
- const authToken = this.getAccessToken();
1413
- if (!authToken) {
1414
- throw new Error('Not authenticated. Run vigthoria login first.');
1415
- }
1416
- const response = await axios.post(`${baseUrl}/api/auth/sso`, { token: authToken }, {
1417
- headers: {
1418
- 'Content-Type': 'application/json',
1419
- },
1420
- timeout: 30000,
1421
- });
1422
- const payload = response.data;
1423
- const vigFlowToken = String(payload.token || '').trim();
1424
- if (!vigFlowToken) {
1425
- throw new Error('VigFlow SSO response did not include a token.');
1426
- }
1427
- this.vigFlowTokens.set(baseUrl, vigFlowToken);
1428
- return vigFlowToken;
1429
- }
1430
- async getVigFlowHeaders(baseUrl) {
1431
- const token = await this.getVigFlowAccessToken(baseUrl);
1432
- return {
1433
- 'Content-Type': 'application/json',
1434
- Accept: 'application/json',
1435
- Authorization: `Bearer ${token}`,
1436
- };
1437
- }
1438
- async withVigFlow(operation, action) {
1439
- let lastError = null;
1440
- for (const baseUrl of this.getVigFlowBaseUrls()) {
1441
- try {
1442
- const headers = await this.getVigFlowHeaders(baseUrl);
1443
- return await action(baseUrl, headers);
1444
- }
1445
- catch (error) {
1446
- lastError = error;
1447
- this.logger.debug(`VigFlow ${operation} via ${baseUrl} failed:`, lastError.message);
1448
- }
1449
- }
1450
- // Throw a clean message instead of the raw ECONNREFUSED from the last URL tried
1451
- throw new Error(`No VigFlow backend available for ${operation}. The workflow service is not deployed or not reachable.`);
1452
- }
1453
- /**
1454
- * Build the correct sub-path for VigFlow endpoints.
1455
- * Local servers (e.g. localhost:5060) need `/api/…` prefix.
1456
- * The remote gateway URL already ends with `/api/vigflow`, so appending
1457
- * another `/api/…` would double the prefix and cause 404s.
1458
- */
1459
- vigFlowEndpoint(baseUrl, subPath) {
1460
- if (/\/api\/vigflow\/?$/i.test(baseUrl)) {
1461
- // Remote gateway – subPath like '/templates' is enough
1462
- return `${baseUrl.replace(/\/$/, '')}${subPath}`;
1463
- }
1464
- return `${baseUrl}/api${subPath}`;
1465
- }
1466
- async listVigFlowTemplates(options = {}) {
1467
- return this.withVigFlow('list templates', async (baseUrl, headers) => {
1468
- const query = new URLSearchParams();
1469
- if (options.category) {
1470
- query.set('category', options.category);
1471
- }
1472
- if (options.search) {
1473
- query.set('search', options.search);
1474
- }
1475
- const url = `${this.vigFlowEndpoint(baseUrl, '/templates')}${query.size > 0 ? `?${query.toString()}` : ''}`;
1476
- const response = await axios.get(url, {
1477
- headers,
1478
- timeout: 30000,
1479
- });
1480
- const payload = response.data;
1481
- return Array.isArray(payload.templates) ? payload.templates : [];
1482
- });
1483
- }
1484
- async listVigFlowWorkflows() {
1485
- return this.withVigFlow('list workflows', async (baseUrl, headers) => {
1486
- const response = await axios.get(this.vigFlowEndpoint(baseUrl, '/workflows'), {
1487
- headers,
1488
- timeout: 30000,
1489
- });
1490
- const payload = response.data;
1491
- return Array.isArray(payload.workflows) ? payload.workflows : [];
1492
- });
1493
- }
1494
- async resolveVigFlowWorkflow(selector) {
1495
- const normalizedSelector = String(selector || '').trim();
1496
- if (!normalizedSelector) {
1497
- throw new Error('Workflow selector is required. Provide a workflow id or name.');
1498
- }
1499
- const workflows = await this.listVigFlowWorkflows();
1500
- const byId = workflows.find((workflow) => workflow.id === normalizedSelector);
1501
- if (byId) {
1502
- return {
1503
- id: byId.id,
1504
- name: byId.name,
1505
- selector: normalizedSelector,
1506
- matchedBy: 'id',
1507
- };
1508
- }
1509
- const loweredSelector = normalizedSelector.toLowerCase();
1510
- const exactNameMatches = workflows.filter((workflow) => String(workflow.name || '').trim().toLowerCase() === loweredSelector);
1511
- if (exactNameMatches.length === 1) {
1512
- return {
1513
- id: exactNameMatches[0].id,
1514
- name: exactNameMatches[0].name,
1515
- selector: normalizedSelector,
1516
- matchedBy: 'name',
1517
- };
1518
- }
1519
- if (exactNameMatches.length > 1) {
1520
- throw new Error(`Multiple workflows matched the name "${normalizedSelector}". Use the workflow id instead.`);
1521
- }
1522
- const searchMatches = workflows.filter((workflow) => String(workflow.name || '').toLowerCase().includes(loweredSelector));
1523
- if (searchMatches.length === 1) {
1524
- return {
1525
- id: searchMatches[0].id,
1526
- name: searchMatches[0].name,
1527
- selector: normalizedSelector,
1528
- matchedBy: 'search',
1529
- };
1530
- }
1531
- if (searchMatches.length > 1) {
1532
- throw new Error(`Multiple workflows partially matched "${normalizedSelector}". Use a full workflow name or id.`);
1533
- }
1534
- throw new Error(`No VigFlow workflow matched "${normalizedSelector}".`);
1535
- }
1536
- async useVigFlowTemplate(templateId, options = {}) {
1537
- return this.withVigFlow('use template', async (baseUrl, headers) => {
1538
- const response = await axios.post(`${this.vigFlowEndpoint(baseUrl, `/templates/${encodeURIComponent(templateId)}/use`)}`, {
1539
- name: options.name,
1540
- variables: options.variables || {},
1541
- }, {
1542
- headers,
1543
- timeout: 30000,
1544
- });
1545
- const payload = response.data;
1546
- if (!payload.workflow?.id) {
1547
- throw new Error('VigFlow use-template response did not include a workflow id.');
1548
- }
1549
- return payload.workflow;
1550
- });
1551
- }
1552
- async runVigFlowWorkflow(workflowId, options = {}) {
1553
- return this.withVigFlow('run workflow', async (baseUrl, headers) => {
1554
- const response = await axios.post(`${this.vigFlowEndpoint(baseUrl, `/executions/run/${encodeURIComponent(workflowId)}`)}`, {
1555
- data: options.data || {},
1556
- options: options.executionOptions || {},
1557
- }, {
1558
- headers,
1559
- timeout: 60000,
1560
- });
1561
- const payload = response.data;
1562
- if (!payload.executionId) {
1563
- throw new Error('VigFlow run response did not include an execution id.');
1564
- }
1565
- return payload;
1566
- });
1567
- }
1568
- async getVigFlowExecutionStatus(executionId) {
1569
- return this.withVigFlow('execution status', async (baseUrl, headers) => {
1570
- const response = await axios.get(`${this.vigFlowEndpoint(baseUrl, `/executions/${encodeURIComponent(executionId)}`)}`, {
1571
- headers,
1572
- timeout: 30000,
1573
- });
1574
- const payload = response.data;
1575
- if (!payload.execution?.id) {
1576
- throw new Error('VigFlow execution response did not include execution details.');
1577
- }
1578
- return payload.execution;
1579
- });
1580
- }
1581
- /** Legacy cap — prefer resolveV3ContextCharLimit() for token-aligned budgets. */
1582
- static V3_CONTEXT_CHAR_LIMIT = 100_000;
1583
820
  static HYDRATION_PRIORITY_BASENAMES = new Set([
1584
821
  'package.json', 'index.html', 'game.js', 'main.js', 'app.js', 'script.js',
1585
822
  'styles.css', 'style.css', 'README.md', 'manifest.json',
@@ -1634,7 +871,6 @@ export class APIClient {
1634
871
  }
1635
872
  buildV3AgentContext(context = {}) {
1636
873
  const resolvedContext = this.ensureExecutionContext(context);
1637
- const targetPath = resolvedContext.targetPath || resolvedContext.projectPath || resolvedContext.workspacePath || resolvedContext.projectRoot || process.cwd();
1638
874
  const localWorkspacePath = this.resolveAgentTargetPath(resolvedContext);
1639
875
  const serverWorkspacePath = this.resolveServerBindableWorkspacePath(resolvedContext);
1640
876
  const promptFocus = String(resolvedContext.rawPrompt || resolvedContext.contextualPrompt || resolvedContext.prompt || '');
@@ -1687,6 +923,11 @@ export class APIClient {
1687
923
  executionHints.requires_file_changes = false;
1688
924
  }
1689
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;
1690
931
  const payload = {
1691
932
  workspace: this.buildPublicWorkspaceDescriptor(resolvedContext.workspace, {
1692
933
  localWorkspacePath,
@@ -1738,11 +979,13 @@ export class APIClient {
1738
979
  codebaseContext: resolvedContext.codebaseContext || '',
1739
980
  accountBrainContext: resolvedContext.accountBrainContext || '',
1740
981
  projectMemory: resolvedContext.projectMemory || '',
982
+ contextState,
983
+ contextCompatibilityAdapters: contextState?.compatibilityAdapters || null,
1741
984
  clientManifest: buildClientManifest(resolvedContext.clientManifest && typeof resolvedContext.clientManifest === 'object'
1742
985
  ? resolvedContext.clientManifest
1743
986
  : {}),
1744
987
  };
1745
- return this.compactV3Context(payload);
988
+ return this.compactV3Context(scanOutboundContext(payload).value);
1746
989
  }
1747
990
  /**
1748
991
  * Compact a V3 context payload so the serialized JSON stays under
@@ -1875,34 +1118,35 @@ export class APIClient {
1875
1118
  }
1876
1119
  return finish();
1877
1120
  }
1878
- /** Normalize common Unix-only commands before Windows cmd/powershell execution. */
1879
- normalizeWindowsRunCommand(command, serverRoot = '') {
1880
- 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();
1881
1124
  if (!cmd)
1882
1125
  return cmd;
1883
- if (serverRoot) {
1884
- const escapedRoot = serverRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1885
- cmd = cmd.replace(new RegExp(`^\\s*cd\\s+(?:[\"']?${escapedRoot}[\"']?)\\s*(?:&&|;)\\s*`, 'i'), '');
1886
- }
1887
- if (/^\/[^\s/]/.test(cmd) && !/^\/[a-z]:/i.test(cmd)) {
1888
- throw new Error('Command starts with "/" which Windows interprets as an invalid switch. Use workspace-relative paths without a leading slash.');
1889
- }
1890
- const replacements = [
1891
- [/^ls(\s|$)/i, 'dir$1'],
1892
- [/^cat\s+/i, 'type '],
1893
- [/^pwd\s*$/i, 'cd'],
1894
- [/^rm\s+-rf?\s+/i, 'rmdir /s /q '],
1895
- [/^rm\s+/i, 'del '],
1896
- [/^cp\s+/i, 'copy '],
1897
- [/^mv\s+/i, 'move '],
1898
- ];
1899
- for (const [pattern, replacement] of replacements) {
1900
- cmd = cmd.replace(pattern, replacement);
1901
- }
1902
- 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;
1903
1144
  }
1904
1145
  resolveRunCommandCwd(rootPath, args, serverRoot) {
1905
- 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 ?? '.';
1906
1150
  let target = this.resolveV3ClientToolPath(rootPath, cwdRaw, serverRoot ? [serverRoot] : []);
1907
1151
  if (!target) {
1908
1152
  target = this.resolveV3ClientToolPath(rootPath, '.', serverRoot ? [serverRoot] : []);
@@ -2027,6 +1271,7 @@ export class APIClient {
2027
1271
  requestStartedAt: resolvedContext.requestStartedAt,
2028
1272
  vigthoriaBrain: dedupedBrain,
2029
1273
  vigthoria_brain: dedupedBrain,
1274
+ contextState: resolvedContext.contextState || null,
2030
1275
  });
2031
1276
  }
2032
1277
  extractEmergencyAppName(message = '', fallback = 'Signal Desk') {
@@ -2497,18 +1742,18 @@ menu {
2497
1742
  }
2498
1743
  async bindExecutionContext(context = {}) {
2499
1744
  const executionContext = this.ensureExecutionContext(context);
2500
- const headers = await this.getMcpHeaders();
2501
1745
  const localWorkspacePath = this.resolveAgentTargetPath(executionContext);
2502
1746
  const workspacePath = this.resolveServerBindableWorkspacePath(executionContext);
2503
1747
  const localWorkspaceSummary = this.buildLocalWorkspaceSummary(localWorkspacePath, String(executionContext.rawPrompt || executionContext.contextualPrompt || executionContext.prompt || ''));
1748
+ const workspaceAlias = `vigthoria://workspace/${encodeURIComponent(path.basename(localWorkspacePath || workspacePath || 'workspace'))}`;
2504
1749
  const metadata = {
2505
1750
  source: 'vigthoria-cli',
2506
1751
  sharedContextId: executionContext.contextId,
2507
1752
  traceId: executionContext.traceId,
2508
1753
  executionSurface: executionContext.executionSurface || 'cli',
2509
1754
  clientSurface: executionContext.clientSurface || 'cli',
2510
- workspacePath: workspacePath || null,
2511
- localWorkspacePath: localWorkspacePath || null,
1755
+ workspacePath: workspaceAlias,
1756
+ localWorkspacePath: workspaceAlias,
2512
1757
  localWorkspaceSummary,
2513
1758
  runtimeEnvironment: executionContext.agentRuntime || null,
2514
1759
  requestStartedAt: executionContext.requestStartedAt,
@@ -2519,10 +1764,10 @@ menu {
2519
1764
  sharedContextId: executionContext.contextId,
2520
1765
  traceId: executionContext.traceId,
2521
1766
  requestStartedAt: executionContext.requestStartedAt,
2522
- workspacePath: workspacePath || null,
2523
- projectPath: workspacePath || null,
2524
- targetPath: workspacePath || null,
2525
- localWorkspacePath: localWorkspacePath || null,
1767
+ workspacePath: workspaceAlias,
1768
+ projectPath: workspaceAlias,
1769
+ targetPath: workspaceAlias,
1770
+ localWorkspacePath: workspaceAlias,
2526
1771
  localWorkspaceSummary,
2527
1772
  runtimeEnvironment: executionContext.agentRuntime || null,
2528
1773
  activeFile: executionContext.activeFile || null,
@@ -2531,68 +1776,22 @@ menu {
2531
1776
  userId: this.config.get('userId') || null,
2532
1777
  email: this.config.get('email') || null,
2533
1778
  };
2534
- const updateExisting = async (binding) => {
2535
- const response = await fetch(this.getMcpContextUrl(binding.backendUrl, binding.mcpContextId), {
2536
- method: 'PUT',
2537
- headers,
2538
- body: JSON.stringify({ data }),
2539
- });
2540
- if (!response.ok) {
2541
- const errorText = await response.text().catch(() => '');
2542
- throw new Error(`MCP context update ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2543
- }
2544
- return {
2545
- ...executionContext,
2546
- localWorkspacePath: localWorkspacePath || executionContext.localWorkspacePath || null,
2547
- targetPath: localWorkspacePath || executionContext.targetPath || executionContext.projectPath || executionContext.workspacePath || null,
2548
- projectPath: localWorkspacePath || executionContext.projectPath || executionContext.targetPath || executionContext.workspacePath || null,
2549
- workspacePath: localWorkspacePath || executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || null,
2550
- mcpContextId: binding.mcpContextId,
2551
- mcpContextBackendUrl: binding.backendUrl,
2552
- };
2553
- };
2554
- if (executionContext.mcpContextId && executionContext.mcpContextBackendUrl) {
2555
- try {
2556
- return await updateExisting({
2557
- mcpContextId: String(executionContext.mcpContextId),
2558
- backendUrl: String(executionContext.mcpContextBackendUrl),
2559
- });
2560
- }
2561
- catch (error) {
2562
- this.logger.debug('Failed to refresh existing MCP context binding:', error.message);
2563
- }
2564
- }
2565
- for (const baseUrl of this.getMcpBaseUrls()) {
2566
- try {
2567
- const createResponse = await fetch(this.getMcpContextCreateUrl(baseUrl), {
2568
- method: 'POST',
2569
- headers,
2570
- body: JSON.stringify({
2571
- userId: this.config.get('userId') || this.config.get('email') || 'vigthoria-cli',
2572
- metadata,
2573
- }),
2574
- });
2575
- if (!createResponse.ok) {
2576
- const errorText = await createResponse.text().catch(() => '');
2577
- throw new Error(`MCP context create ${createResponse.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
2578
- }
2579
- const payload = await createResponse.json();
2580
- const mcpContextId = String(payload.contextId || '').trim();
2581
- if (!mcpContextId) {
2582
- throw new Error('MCP context create response did not include a contextId');
2583
- }
2584
- return await updateExisting({ mcpContextId, backendUrl: baseUrl });
2585
- }
2586
- catch (error) {
2587
- this.logger.debug(`Failed to bind MCP context via ${baseUrl}:`, error.message);
2588
- }
2589
- }
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
+ });
2590
1788
  return {
2591
1789
  ...executionContext,
2592
1790
  localWorkspacePath: localWorkspacePath || executionContext.localWorkspacePath || null,
2593
1791
  targetPath: localWorkspacePath || executionContext.targetPath || executionContext.projectPath || executionContext.workspacePath || null,
2594
1792
  projectPath: localWorkspacePath || executionContext.projectPath || executionContext.targetPath || executionContext.workspacePath || null,
2595
1793
  workspacePath: localWorkspacePath || executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || null,
1794
+ ...(binding ? { mcpContextId: binding.mcpContextId, mcpContextBackendUrl: binding.backendUrl } : {}),
2596
1795
  };
2597
1796
  }
2598
1797
  isServerManagedTempWorkspacePath(pathValue) {
@@ -2704,7 +1903,7 @@ menu {
2704
1903
  files: [],
2705
1904
  };
2706
1905
  const snapshot = this.getAgentWorkspaceSnapshot(rootPath);
2707
- const candidatePaths = snapshot.paths.filter((entry) => !/(^|[\/\\])\.vigthoria([\/\\]|$)/.test(entry));
1906
+ const candidatePaths = snapshot.paths.filter((entry) => !isSensitivePath(entry));
2708
1907
  const focusFiles = this.extractExpectedWorkspaceFiles(requestFocus)
2709
1908
  .map((entry) => this.normalizeWorkspaceRelativePath(entry))
2710
1909
  .filter((entry) => candidatePaths.includes(entry));
@@ -2726,7 +1925,9 @@ menu {
2726
1925
  }
2727
1926
  }
2728
1927
  const hydrationFocusFiles = [...focusFiles, ...linkedFocusAssets];
2729
- 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));
2730
1931
  let orderedPaths = [...agentStatePaths, ...hydrationFocusFiles, ...candidatePaths];
2731
1932
  try {
2732
1933
  const ranked = buildSemanticContext(rootPath, requestFocus, 60).topFiles.map((file) => file.path);
@@ -2807,271 +2008,28 @@ menu {
2807
2008
  * Budget: up to ~2 MB total, per-file cap 200 KB, skip binary extensions.
2808
2009
  */
2809
2010
  collectWorkspaceFileContents(rootPath, filePaths) {
2810
- const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
2811
- const MAX_FILE_BYTES = 200 * 1024;
2812
- const BINARY_EXTENSIONS = new Set([
2813
- '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif',
2814
- '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.flac', '.aac',
2815
- '.zip', '.gz', '.tar', '.rar', '.7z', '.bz2',
2816
- '.exe', '.dll', '.so', '.dylib', '.bin', '.dat',
2817
- '.woff', '.woff2', '.ttf', '.eot', '.otf',
2818
- '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
2819
- '.db', '.sqlite', '.sqlite3',
2820
- '.pyc', '.pyo', '.class', '.o', '.obj',
2821
- '.DS_Store', '.lock',
2822
- ]);
2823
- const result = {};
2824
- let totalBytes = 0;
2825
- for (const relativePath of filePaths) {
2826
- if (totalBytes >= MAX_TOTAL_BYTES)
2827
- break;
2828
- const ext = path.extname(relativePath).toLowerCase();
2829
- if (BINARY_EXTENSIONS.has(ext))
2830
- continue;
2831
- if (/(^|[\/\\])\.(git|hg)([\/\\]|$)/.test(relativePath))
2832
- continue;
2833
- const normalizedPath = relativePath.replace(/\\/g, '/');
2834
- const isAgentStateSync = /^\.vigthoria\/agent-state\//i.test(normalizedPath);
2835
- if (!isAgentStateSync && /(^|[\/\\])\.vigthoria([\/\\]|$)/.test(relativePath))
2836
- continue;
2837
- const absolutePath = path.join(rootPath, relativePath);
2838
- try {
2839
- const stat = fs.statSync(absolutePath);
2840
- if (!stat.isFile() || stat.size > MAX_FILE_BYTES || stat.size === 0)
2841
- continue;
2842
- const content = fs.readFileSync(absolutePath, 'utf8');
2843
- // Skip likely binary content (high ratio of non-printable chars)
2844
- if (/[\x00-\x08\x0e-\x1f]/.test(content.slice(0, 512)))
2845
- continue;
2846
- result[relativePath] = content;
2847
- totalBytes += Buffer.byteLength(content, 'utf8');
2848
- }
2849
- catch {
2850
- // Skip unreadable files
2851
- }
2852
- }
2853
- return result;
2011
+ return this.v3WorkspaceService.collectFileContents(rootPath, filePaths);
2854
2012
  }
2855
2013
  hasAgentWorkspaceOutput(context = {}) {
2856
- try {
2857
- const root = this.resolveAgentTargetPath(context);
2858
- if (!root || !fs.existsSync(root)) {
2859
- return false;
2860
- }
2861
- const stack = [root];
2862
- while (stack.length > 0) {
2863
- const current = stack.pop();
2864
- if (!current)
2865
- continue;
2866
- const entries = fs.readdirSync(current, { withFileTypes: true });
2867
- for (const entry of entries) {
2868
- if (entry.name === '.git' || entry.name === 'node_modules') {
2869
- continue;
2870
- }
2871
- const fullPath = path.join(current, entry.name);
2872
- if (entry.isFile()) {
2873
- return true;
2874
- }
2875
- if (entry.isDirectory()) {
2876
- stack.push(fullPath);
2877
- }
2878
- }
2879
- }
2880
- }
2881
- catch {
2882
- return false;
2883
- }
2884
- return false;
2014
+ return this.v3WorkspaceService.hasOutput(context);
2885
2015
  }
2886
2016
  getAgentWorkspaceSnapshot(rootPath) {
2887
- const stack = [rootPath];
2888
- let fileCount = 0;
2889
- const entries = [];
2890
- while (stack.length > 0) {
2891
- const current = stack.pop();
2892
- if (!current) {
2893
- continue;
2894
- }
2895
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
2896
- if (entry.name === '.git' || entry.name === 'node_modules') {
2897
- continue;
2898
- }
2899
- const fullPath = path.join(current, entry.name);
2900
- if (entry.isDirectory()) {
2901
- stack.push(fullPath);
2902
- continue;
2903
- }
2904
- if (!entry.isFile()) {
2905
- continue;
2906
- }
2907
- fileCount += 1;
2908
- const stat = fs.statSync(fullPath);
2909
- entries.push(`${path.relative(rootPath, fullPath)}:${stat.size}:${stat.mtimeMs}`);
2910
- }
2911
- }
2912
- entries.sort();
2913
- return {
2914
- fileCount,
2915
- paths: entries.map((entry) => entry.split(':', 1)[0]),
2916
- signature: entries.join('|'),
2917
- };
2017
+ return this.v3WorkspaceService.snapshot(rootPath);
2918
2018
  }
2919
2019
  async waitForAgentWorkspaceSettle(context = {}, options = {}) {
2920
- const rootPath = this.resolveAgentTargetPath(context);
2921
- if (!rootPath || !fs.existsSync(rootPath)) {
2922
- return;
2923
- }
2924
- const timeoutMs = options.timeoutMs || 15000;
2925
- const pollMs = options.pollMs || 300;
2926
- const stableMs = options.stableMs || 1200;
2927
- const expectedFiles = Array.isArray(options.expectedFiles)
2928
- ? options.expectedFiles.map((entry) => String(entry || '').trim()).filter(Boolean)
2929
- : [];
2930
- const start = Date.now();
2931
- let stableSince = 0;
2932
- let lastSignature = '';
2933
- while (Date.now() - start < timeoutMs) {
2934
- const snapshot = this.getAgentWorkspaceSnapshot(rootPath);
2935
- if (snapshot.fileCount === 0) {
2936
- stableSince = 0;
2937
- lastSignature = '';
2938
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2939
- continue;
2940
- }
2941
- if (expectedFiles.length > 0 && !expectedFiles.every((filePath) => snapshot.paths.includes(filePath))) {
2942
- stableSince = 0;
2943
- lastSignature = snapshot.signature;
2944
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2945
- continue;
2946
- }
2947
- if (snapshot.signature === lastSignature) {
2948
- if (!stableSince) {
2949
- stableSince = Date.now();
2950
- }
2951
- if (Date.now() - stableSince >= stableMs) {
2952
- return;
2953
- }
2954
- }
2955
- else {
2956
- lastSignature = snapshot.signature;
2957
- stableSince = Date.now();
2958
- }
2959
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2960
- }
2020
+ return this.v3WorkspaceService.waitForSettle(context, options);
2961
2021
  }
2962
2022
  extractExpectedWorkspaceFiles(message = '', context = {}) {
2963
- const candidates = new Set();
2964
- const addMatches = (value) => {
2965
- const text = String(value || '');
2966
- const patterns = [
2967
- /`([^`]+\.(?:html|css|js|jsx|ts|tsx|json|md|py|sh))`/gi,
2968
- /\b([A-Za-z0-9_./-]+\.(?:html|css|js|jsx|ts|tsx|json|md|py|sh))\b/g,
2969
- ];
2970
- for (const pattern of patterns) {
2971
- let match;
2972
- while ((match = pattern.exec(text)) !== null) {
2973
- const filePath = match[1].trim().replace(/^\.\//, '');
2974
- if (filePath && !filePath.startsWith('http://') && !filePath.startsWith('https://')) {
2975
- candidates.add(filePath);
2976
- }
2977
- }
2978
- }
2979
- };
2980
- addMatches(message);
2981
- addMatches(context.rawMessage);
2982
- addMatches(context.agentPrompt);
2983
- return Array.from(candidates);
2023
+ return this.v3WorkspaceService.extractExpectedFiles(message, context);
2984
2024
  }
2985
2025
  captureV3AgentStreamMutation(event, streamedFiles, serverRoot) {
2986
- if (!event || !streamedFiles) {
2987
- return;
2988
- }
2989
- if (event.type === 'file_mutation' && typeof event.path === 'string') {
2990
- const filePath = this.normalizeAgentWorkspaceRelativePath(event.path, serverRoot || undefined);
2991
- if (!filePath) {
2992
- return;
2993
- }
2994
- if (event.action === 'delete') {
2995
- delete streamedFiles[filePath];
2996
- return;
2997
- }
2998
- if (typeof event.content === 'string') {
2999
- streamedFiles[filePath] = event.content;
3000
- }
3001
- return;
3002
- }
3003
- // workspace_snapshot is handled by applyV3AgentStreamEventToWorkspace only —
3004
- // never count hydrated baselines as "files changed".
3005
- // Only count confirmed mutations — not workspace snapshots (hydrated baseline)
3006
- // or tool_call previews that may still fail client-side / read-only guards.
3007
- if (event.type === 'tool_result' && event.success) {
3008
- const name = String(event.name || event.tool || '').trim();
3009
- const args = event.arguments || {};
3010
- const targetPath = typeof event.target === 'string'
3011
- ? event.target
3012
- : (typeof args.path === 'string' ? args.path : '');
3013
- if ((name === 'write_file' || name === 'edit_file') && targetPath) {
3014
- const filePath = this.normalizeAgentWorkspaceRelativePath(targetPath, serverRoot || undefined);
3015
- if (filePath && typeof args.content === 'string') {
3016
- streamedFiles[filePath] = args.content;
3017
- }
3018
- }
3019
- }
2026
+ this.v3WorkspaceService.captureMutation(event, streamedFiles, serverRoot);
3020
2027
  }
3021
2028
  applyV3AgentStreamEventToWorkspace(event, context = {}, serverRoot) {
3022
- if (!event || !['file_mutation', 'workspace_snapshot'].includes(String(event.type || ''))) {
3023
- return false;
3024
- }
3025
- const rootPath = this.resolveAgentTargetPath(context);
3026
- if (!rootPath) {
3027
- return false;
3028
- }
3029
- if (event.type === 'workspace_snapshot' && event.files && typeof event.files === 'object') {
3030
- let applied = false;
3031
- for (const [rawPath, content] of Object.entries(event.files)) {
3032
- if (typeof content !== 'string') {
3033
- continue;
3034
- }
3035
- applied = this.writeV3AgentWorkspaceFile(rootPath, rawPath, content, serverRoot || undefined) || applied;
3036
- }
3037
- return applied;
3038
- }
3039
- if (event.type === 'file_mutation' && typeof event.path === 'string') {
3040
- const relativePath = this.normalizeAgentWorkspaceRelativePath(event.path, serverRoot || undefined);
3041
- if (!relativePath) {
3042
- return false;
3043
- }
3044
- if (event.action === 'delete') {
3045
- return this.deleteV3AgentWorkspaceFile(rootPath, relativePath);
3046
- }
3047
- if (typeof event.content === 'string') {
3048
- return this.writeV3AgentWorkspaceFile(rootPath, relativePath, event.content, rootPath);
3049
- }
3050
- }
3051
- return false;
2029
+ return this.v3WorkspaceService.applyStreamEvent(event, context, serverRoot);
3052
2030
  }
3053
2031
  resolveV3ClientToolPath(rootPath, rawPath, sourceRoots = []) {
3054
- const raw = String(rawPath || '.').trim();
3055
- const roots = [rootPath, ...sourceRoots.filter(Boolean)];
3056
- let relativePath = '';
3057
- for (const sourceRoot of roots) {
3058
- relativePath = this.normalizeAgentWorkspaceRelativePath(raw, sourceRoot);
3059
- if (relativePath || raw === '.' || raw === '') {
3060
- break;
3061
- }
3062
- }
3063
- if (!relativePath && raw !== '.' && raw !== '') {
3064
- const treatAsWorkspaceRoot = roots.some((sourceRoot) => isV3WorkspaceRootAliasPath(raw, sourceRoot));
3065
- if (!treatAsWorkspaceRoot) {
3066
- return null;
3067
- }
3068
- }
3069
- const absolutePath = relativePath ? joinV3WorkspacePath(rootPath, relativePath) : path.resolve(rootPath);
3070
- const resolvedRoot = path.resolve(rootPath);
3071
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3072
- return null;
3073
- }
3074
- return { relativePath: relativePath || '.', absolutePath };
2032
+ return this.v3WorkspaceService.resolveClientToolPath(rootPath, rawPath, sourceRoots);
3075
2033
  }
3076
2034
  recordV3ClientToolMutation(event, context, result) {
3077
2035
  if (!result.success) {
@@ -3117,7 +2075,7 @@ menu {
3117
2075
  const serverRoot = String(context.__v3ServerWorkspaceRoot || '').trim();
3118
2076
  const isRunCommand = name === 'run_command';
3119
2077
  const pathArg = isRunCommand
3120
- ? (args.cwd ?? args.path ?? args.directory ?? '.')
2078
+ ? (args.cwd ?? args.directory ?? '.')
3121
2079
  : (args.path || args.directory || args.cwd || '.');
3122
2080
  let target = this.resolveV3ClientToolPath(rootPath, pathArg, serverRoot ? [serverRoot] : []);
3123
2081
  if (!target && isRunCommand) {
@@ -3151,7 +2109,26 @@ menu {
3151
2109
  return result;
3152
2110
  }
3153
2111
  if (name === 'read_file') {
3154
- 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
+ };
3155
2132
  }
3156
2133
  if (name === 'list_directory') {
3157
2134
  const entries = fs.readdirSync(target.absolutePath, { withFileTypes: true })
@@ -3162,6 +2139,15 @@ menu {
3162
2139
  if (name === 'glob' || name === 'search_files' || name === 'grep') {
3163
2140
  const rawPattern = String(args.pattern || args.query || '').trim();
3164
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
+ }
3165
2151
  const matches = [];
3166
2152
  const walk = (dir) => {
3167
2153
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -3176,8 +2162,11 @@ menu {
3176
2162
  if (name !== 'glob' && pattern) {
3177
2163
  try {
3178
2164
  const content = fs.readFileSync(absolute, 'utf8');
3179
- if (relative.toLowerCase().includes(pattern.replace(/\*/g, '')) || content.toLowerCase().includes(pattern)) {
3180
- 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)'}`);
3181
2170
  }
3182
2171
  }
3183
2172
  catch {
@@ -3193,9 +2182,19 @@ menu {
3193
2182
  cwd: searchRoot,
3194
2183
  nodir: true,
3195
2184
  dot: false,
2185
+ follow: false,
3196
2186
  ignore: ['**/node_modules/**', '**/.git/**'],
3197
2187
  })
3198
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
+ })
3199
2198
  .sort((left, right) => left.localeCompare(right));
3200
2199
  return { success: true, output: globbed.slice(0, 200).join('\n') || '(no matches)' };
3201
2200
  }
@@ -3224,9 +2223,9 @@ menu {
3224
2223
  return { success: true, output: `Syntax check passed: ${target.relativePath}` };
3225
2224
  }
3226
2225
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
3227
- const { execSync } = await import('child_process');
2226
+ const { execFileSync } = await import('child_process');
3228
2227
  try {
3229
- execSync(`node --check "${target.absolutePath}"`, { stdio: 'pipe' });
2228
+ execFileSync(process.execPath, ['--check', target.absolutePath], { stdio: 'pipe', timeout: 10_000, windowsHide: true, env: safeChildProcessEnv() });
3230
2229
  return { success: true, output: `Syntax check passed: ${target.relativePath}` };
3231
2230
  }
3232
2231
  catch (error) {
@@ -3242,36 +2241,73 @@ menu {
3242
2241
  if (!command)
3243
2242
  return { success: false, output: '', error: 'run_command requires command.' };
3244
2243
  try {
3245
- if (process.platform === 'win32') {
3246
- command = this.normalizeWindowsRunCommand(command, serverRoot);
3247
- }
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);
3248
2252
  }
3249
2253
  catch (error) {
3250
2254
  return { success: false, output: '', error: error.message };
3251
2255
  }
3252
- const cwd = this.resolveRunCommandCwd(rootPath, args, serverRoot);
3253
- 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;
3254
2286
  return await new Promise((resolve) => {
3255
- const timeout = Number(args.timeout || 30000);
3256
2287
  const finish = (error, stdout, stderr) => {
3257
2288
  const stdoutText = String(stdout || '');
3258
2289
  const stderrText = String(stderr || '');
3259
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
+ : '';
3260
2297
  resolve({
3261
2298
  success: !error,
3262
2299
  output: combined,
3263
- error: error ? String(error.message || error) : '',
2300
+ error: failureDetail,
3264
2301
  });
3265
2302
  };
3266
- if (process.platform === 'win32') {
3267
- const commandProcessor = process.env.ComSpec
3268
- || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
3269
- execFile(commandProcessor, ['/d', '/s', '/c', command], {
3270
- cwd, timeout, maxBuffer: 1024 * 1024, env: process.env, windowsHide: true,
3271
- }, finish);
3272
- return;
3273
- }
3274
- 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);
3275
2311
  });
3276
2312
  }
3277
2313
  return { success: false, output: '', error: `Unsupported client tool: ${name}` };
@@ -3310,84 +2346,13 @@ menu {
3310
2346
  }
3311
2347
  }
3312
2348
  writeV3AgentWorkspaceFile(rootPath, rawPath, content, sourceRoot) {
3313
- const relativePath = this.normalizeAgentWorkspaceRelativePath(rawPath, sourceRoot || rootPath);
3314
- if (!relativePath) {
3315
- return false;
3316
- }
3317
- const absolutePath = joinV3WorkspacePath(rootPath, relativePath);
3318
- const resolvedRoot = path.resolve(rootPath);
3319
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3320
- this.logger.warn(`Refusing to write V3 file outside workspace: ${rawPath}`);
3321
- return false;
3322
- }
3323
- try {
3324
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3325
- if (fs.existsSync(absolutePath)) {
3326
- const existing = fs.readFileSync(absolutePath, 'utf8');
3327
- if (existing === content) {
3328
- return false;
3329
- }
3330
- }
3331
- fs.writeFileSync(absolutePath, content, 'utf8');
3332
- return true;
3333
- }
3334
- catch (error) {
3335
- this.logger.warn(`Failed to write V3 file ${relativePath}: ${error.message}`);
3336
- return false;
3337
- }
3338
- }
3339
- deleteV3AgentWorkspaceFile(rootPath, rawPath) {
3340
- const relativePath = this.normalizeAgentWorkspaceRelativePath(rawPath, rootPath);
3341
- if (!relativePath) {
3342
- return false;
3343
- }
3344
- const absolutePath = joinV3WorkspacePath(rootPath, relativePath);
3345
- const resolvedRoot = path.resolve(rootPath);
3346
- if (absolutePath !== resolvedRoot && !absolutePath.startsWith(resolvedRoot + path.sep)) {
3347
- this.logger.warn(`Refusing to delete V3 file outside workspace: ${rawPath}`);
3348
- return false;
3349
- }
3350
- try {
3351
- if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
3352
- fs.unlinkSync(absolutePath);
3353
- return true;
3354
- }
3355
- }
3356
- catch (error) {
3357
- this.logger.warn(`Failed to delete V3 file ${relativePath}: ${error.message}`);
3358
- }
3359
- return false;
2349
+ return this.v3WorkspaceService.writeFile(rootPath, rawPath, content, sourceRoot);
3360
2350
  }
3361
2351
  recoverAgentWorkspaceFiles(context = {}, streamedFiles = {}, expectedFiles = []) {
3362
- const rootPath = this.resolveAgentTargetPath(context);
3363
- if (!rootPath || Object.keys(streamedFiles).length === 0) {
3364
- return;
3365
- }
3366
- if (!fs.existsSync(rootPath)) {
3367
- try {
3368
- fs.mkdirSync(rootPath, { recursive: true });
3369
- }
3370
- catch {
3371
- return;
3372
- }
3373
- }
3374
- const targets = expectedFiles.length > 0
3375
- ? Array.from(new Set([...expectedFiles, ...Object.keys(streamedFiles)]))
3376
- : Object.keys(streamedFiles);
3377
- for (const targetPath of targets) {
3378
- const relativePath = this.normalizeAgentWorkspaceRelativePath(targetPath, rootPath);
3379
- if (!relativePath) {
3380
- continue;
3381
- }
3382
- const content = streamedFiles[relativePath];
3383
- if (typeof content !== 'string') {
3384
- continue;
3385
- }
3386
- this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath);
3387
- }
2352
+ this.v3WorkspaceService.recover(context, streamedFiles, expectedFiles);
3388
2353
  }
3389
2354
  normalizeAgentWorkspaceRelativePath(rawPath, rootPath) {
3390
- return normalizeV3WorkspaceRelativePath(rawPath, rootPath);
2355
+ return this.v3WorkspaceService.normalizeRelativePath(rawPath, rootPath);
3391
2356
  }
3392
2357
  async ensureAgentFrontendPolish(message = '', context = {}) {
3393
2358
  const rootPath = this.resolveAgentTargetPath(context);
@@ -3522,14 +2487,23 @@ menu {
3522
2487
  if (!candidate || /^https?:\/\//i.test(candidate) || /^data:/i.test(candidate) || candidate.startsWith('#')) {
3523
2488
  return '';
3524
2489
  }
3525
- 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
+ }
3526
2500
  };
3527
2501
  const ensureTextAsset = (relativePath, content) => {
3528
2502
  const normalized = localAssetPath(relativePath);
3529
2503
  if (!normalized) {
3530
2504
  return '';
3531
2505
  }
3532
- const absolutePath = path.join(rootPath, normalized);
2506
+ const absolutePath = resolveWorkspacePath(rootPath, normalized, { allowMissing: true });
3533
2507
  if (!fs.existsSync(absolutePath)) {
3534
2508
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3535
2509
  fs.writeFileSync(absolutePath, content, 'utf8');
@@ -3542,8 +2516,8 @@ menu {
3542
2516
  const jsRefs = Array.from(html.matchAll(/<script[^>]+src=["']([^"']+\.(?:js|mjs)(?:\?[^"']*)?)["']/gi))
3543
2517
  .map((match) => localAssetPath(match[1]))
3544
2518
  .filter(Boolean);
3545
- let cssPath = cssRefs.map((ref) => path.join(rootPath, ref)).find((candidate) => fs.existsSync(candidate)) || '';
3546
- 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)) || '';
3547
2521
  const cssTemplate = this.buildFallbackFrontendCss(surfaceLabel);
3548
2522
  const jsTemplate = this.buildFallbackFrontendJs(surfaceLabel);
3549
2523
  if (!cssPath && cssRefs.length > 0) {
@@ -3721,8 +2695,18 @@ document.addEventListener('DOMContentLoaded', () => {
3721
2695
  if (!candidate || /^https?:\/\//i.test(candidate) || /^data:/i.test(candidate) || candidate.startsWith('#')) {
3722
2696
  return true;
3723
2697
  }
3724
- const sanitized = candidate.split('?')[0].split('#')[0].replace(/^\//, '');
3725
- 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
+ }
3726
2710
  };
3727
2711
  const repairedHtml = html.replace(/(<img\b[^>]*\ssrc=["'])([^"']+)(["'][^>]*>)/gi, (match, prefix, assetPath, suffix) => {
3728
2712
  if (hasLocalAsset(assetPath)) {
@@ -3733,7 +2717,7 @@ document.addEventListener('DOMContentLoaded', () => {
3733
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>`;
3734
2718
  return `${prefix}data:image/svg+xml;utf8,${encodeURIComponent(svg)}${suffix}`;
3735
2719
  });
3736
- const repairedCss = css.replace(/url\((["']?)([^)"']+)\1\)/gi, (match, quote, assetPath) => {
2720
+ const repairedCss = css.replace(/url\((["']?)([^)"']+)\1\)/gi, (match, _quote, assetPath) => {
3737
2721
  if (hasLocalAsset(assetPath)) {
3738
2722
  return match;
3739
2723
  }
@@ -3920,12 +2904,34 @@ document.addEventListener('DOMContentLoaded', () => {
3920
2904
  : {};
3921
2905
  return { ...streamed, ...clientMutations };
3922
2906
  }
3923
- finalizeV3AgentWorkflowResponse(data, response) {
3924
- 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
+ }
3925
2919
  this.activeV3StreamedFiles = null;
3926
- this.activeV3StreamContext = null;
3927
2920
  return {
3928
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
+ },
3929
2935
  changedFiles,
3930
2936
  };
3931
2937
  }
@@ -3944,7 +2950,6 @@ document.addEventListener('DOMContentLoaded', () => {
3944
2950
  let serverWorkspaceRoot = null;
3945
2951
  const streamedFiles = {};
3946
2952
  this.activeV3StreamedFiles = streamedFiles;
3947
- this.activeV3StreamContext = context;
3948
2953
  context.__v3StreamedFiles = streamedFiles;
3949
2954
  const idleTimeoutMs = (() => {
3950
2955
  const base = context.agentIdleTimeoutMs ?? DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS;
@@ -4205,6 +3210,13 @@ document.addEventListener('DOMContentLoaded', () => {
4205
3210
  }
4206
3211
  async runV3AgentWorkflow(message, context = {}) {
4207
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
+ }
4208
3220
  const requestedTimeoutMs = Number(executionContext.agentTimeoutMs ?? DEFAULT_V3_AGENT_TIMEOUT_MS);
4209
3221
  const baseTimeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0 ? requestedTimeoutMs : 0;
4210
3222
  const expectedFiles = this.extractExpectedWorkspaceFiles(message, executionContext);
@@ -4244,11 +3256,12 @@ document.addEventListener('DOMContentLoaded', () => {
4244
3256
  workspace_files: useRelaxedAttempt ? undefined : workspaceFiles,
4245
3257
  context_id: contextIdOverride ?? requestExecutionContext.contextId,
4246
3258
  mcp_context_id: useRelaxedAttempt ? null : requestExecutionContext.mcpContextId || null,
3259
+ operation_id: executionContext.operationId || undefined,
4247
3260
  stream: true,
4248
3261
  });
4249
3262
  for (const baseUrl of this.getV3AgentBaseUrls(preferLocalV3)) {
4250
3263
  let contextIdOverride;
4251
- const authPreflight = await this.runV3AgentAuthPreflight(baseUrl, buildRequestBody(), requestExecutionContext);
3264
+ const authPreflight = await this.runV3AgentAuthPreflight(baseUrl, requestExecutionContext);
4252
3265
  if (!authPreflight.ok) {
4253
3266
  errors.push(`V3 auth preflight ${authPreflight.status} [${authPreflight.endpoint}]: ${authPreflight.reason || 'Unauthorized'}`);
4254
3267
  continue;
@@ -4299,10 +3312,14 @@ document.addEventListener('DOMContentLoaded', () => {
4299
3312
  const continueTimeoutId = timeoutMs > 0 ? setTimeout(() => continueController.abort(), timeoutMs) : null;
4300
3313
  try {
4301
3314
  const continueHeaders = await this.getV3AgentHeaders();
4302
- 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), {
4303
3320
  method: 'POST',
4304
3321
  headers: { ...continueHeaders, 'Content-Type': 'application/json' },
4305
- body: JSON.stringify(continueBody),
3322
+ body: JSON.stringify(scanOutboundContext(continueBody).value),
4306
3323
  signal: continueController.signal,
4307
3324
  });
4308
3325
  if (!continueResponse.ok) {
@@ -4331,17 +3348,17 @@ document.addEventListener('DOMContentLoaded', () => {
4331
3348
  backendUrl: baseUrl,
4332
3349
  partial: continuationData.checkpointed === true,
4333
3350
  metadata: { source: 'v3-agent', mode: 'agent', contextId: finalContextId, continuations, previewGate },
4334
- });
3351
+ }, mutationJournal);
4335
3352
  }
4336
3353
  const contextId = data.context_id || response.headers.get('x-context-id') || requestExecutionContext.contextId || null;
4337
3354
  const mcpContextId = response.headers.get('x-mcp-context-id') || requestExecutionContext.mcpContextId || null;
4338
3355
  this.recoverAgentWorkspaceFiles(executionContext, data.files || {}, expectedFiles);
4339
3356
  if (data.terminal_error) {
4340
3357
  // A pre-existing local project is not proof that this run produced
4341
- // valid output. Preserve real streamed mutations, but never run or
4342
- // 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.
4343
3360
  const failedPreviewGate = {
4344
- required: false,
3361
+ required: true,
4345
3362
  passed: false,
4346
3363
  skipped: true,
4347
3364
  error: 'Agent execution failed before preview validation.',
@@ -4360,22 +3377,18 @@ document.addEventListener('DOMContentLoaded', () => {
4360
3377
  previewGate: failedPreviewGate,
4361
3378
  workflowError: data.terminal_error,
4362
3379
  },
4363
- });
3380
+ }, mutationJournal);
4364
3381
  }
4365
3382
  await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles });
4366
3383
  await this.ensureAgentFrontendPolish(message, executionContext);
4367
3384
  const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4368
- if (previewGate.required && previewGate.passed !== true && !this.hasAgentWorkspaceOutput(executionContext)) {
4369
- errors.push(`${baseUrl}: workflow rejected the result - ${previewGate.error || 'Workspace changes were not fully validated.'}`);
4370
- continue;
4371
- }
4372
3385
  return this.finalizeV3AgentWorkflowResponse(data, {
4373
3386
  content: this.formatV3AgentResponse(data),
4374
3387
  taskId: data.task_id || null,
4375
3388
  contextId,
4376
3389
  backendUrl: baseUrl,
4377
3390
  metadata: { source: 'v3-agent', mode: 'agent', contextId, mcpContextId, previewGate },
4378
- });
3391
+ }, mutationJournal);
4379
3392
  }
4380
3393
  catch (error) {
4381
3394
  if (error && error.name === 'AbortError' && error.partialData && this.hasAgentWorkspaceOutput(executionContext)) {
@@ -4390,7 +3403,17 @@ document.addEventListener('DOMContentLoaded', () => {
4390
3403
  backendUrl: baseUrl,
4391
3404
  partial: true,
4392
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 },
4393
- });
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;
4394
3417
  }
4395
3418
  errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4396
3419
  }
@@ -4418,7 +3441,7 @@ document.addEventListener('DOMContentLoaded', () => {
4418
3441
  if (onlyUnauthorizedErrors && usingStoredConfigToken) {
4419
3442
  const gatewayTokenCheck = await this.validateToken({ allowNetworkFailOpen: true, enforceTokenShape: true });
4420
3443
  if (!gatewayTokenCheck.valid) {
4421
- this.config.clearAuth();
3444
+ this.authSession.clearLocalCredentials();
4422
3445
  throw new Error('V3 agent authentication failed. The stored CLI login token is invalid or expired. Run vigthoria login again.');
4423
3446
  }
4424
3447
  const rejectedEndpoints = Array.from(new Set(errors
@@ -4440,17 +3463,25 @@ document.addEventListener('DOMContentLoaded', () => {
4440
3463
  await this.waitForAgentWorkspaceSettle(executionContext, { expectedFiles: ['index.html', 'styles.css', 'scripts.js'] });
4441
3464
  await this.ensureAgentFrontendPolish(message, executionContext);
4442
3465
  const previewGate = await this.runTemplateServicePreviewGate(message, executionContext);
4443
- return {
3466
+ return this.finalizeV3AgentWorkflowResponse({}, {
4444
3467
  content: `Recovered a local SaaS workspace scaffold for ${appName} after repeated V3 materialization failures.`,
4445
3468
  taskId: null,
4446
3469
  contextId: executionContext.contextId || null,
4447
3470
  backendUrl: 'local-emergency-scaffold',
4448
3471
  partial: true,
4449
3472
  metadata: { source: 'v3-agent-emergency-scaffold', mode: 'agent', previewGate, emergencyScaffold: true },
4450
- };
3473
+ }, mutationJournal);
4451
3474
  }
4452
3475
  }
4453
- 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;
4454
3485
  }
4455
3486
  async startV3BackgroundJob(message, context = {}) {
4456
3487
  await this.ensureV3ServiceKey();
@@ -4470,1674 +3501,181 @@ document.addEventListener('DOMContentLoaded', () => {
4470
3501
  ...executionContext,
4471
3502
  backgroundJob: true,
4472
3503
  clientToolExecution: false,
4473
- workspaceFilesOutOfBand: !!workspaceFiles,
4474
- }),
4475
- workspace_files: workspaceFiles,
4476
- stream: false,
4477
- model: resolvedModel,
4478
- mcp_context_id: executionContext.mcpContextId || undefined,
4479
- };
4480
- const errors = [];
4481
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4482
- try {
4483
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl), {
4484
- method: 'POST',
4485
- headers: await this.getV3AgentHeaders(),
4486
- body: JSON.stringify(body),
4487
- });
4488
- if (!response.ok) {
4489
- errors.push(`${baseUrl}: ${response.status} ${await response.text().catch(() => '')}`);
4490
- continue;
4491
- }
4492
- return await response.json();
4493
- }
4494
- catch (error) {
4495
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4496
- }
4497
- }
4498
- throw new Error(`Unable to start background job: ${errors.join('; ')}`);
4499
- }
4500
- async listV3BackgroundJobs(limit = 20) {
4501
- await this.ensureV3ServiceKey();
4502
- const errors = [];
4503
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4504
- try {
4505
- const response = await fetch(`${this.getV3AgentBackgroundUrl(baseUrl)}?limit=${encodeURIComponent(String(limit))}`, {
4506
- method: 'GET',
4507
- headers: await this.getV3AgentHeaders(),
4508
- });
4509
- if (!response.ok) {
4510
- errors.push(`${baseUrl}: ${response.status}`);
4511
- continue;
4512
- }
4513
- const data = await response.json();
4514
- return Array.isArray(data.jobs) ? data.jobs : [];
4515
- }
4516
- catch (error) {
4517
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4518
- }
4519
- }
4520
- throw new Error(`Unable to list background jobs: ${errors.join('; ')}`);
4521
- }
4522
- async getV3BackgroundJob(jobId) {
4523
- await this.ensureV3ServiceKey();
4524
- const errors = [];
4525
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4526
- try {
4527
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, encodeURIComponent(jobId)), {
4528
- method: 'GET',
4529
- headers: await this.getV3AgentHeaders(),
4530
- });
4531
- if (!response.ok) {
4532
- errors.push(`${baseUrl}: ${response.status}`);
4533
- continue;
4534
- }
4535
- return await response.json();
4536
- }
4537
- catch (error) {
4538
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4539
- }
4540
- }
4541
- throw new Error(`Unable to get background job: ${errors.join('; ')}`);
4542
- }
4543
- async cancelV3BackgroundJob(jobId) {
4544
- await this.ensureV3ServiceKey();
4545
- const errors = [];
4546
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4547
- try {
4548
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, `${encodeURIComponent(jobId)}/cancel`), {
4549
- method: 'POST',
4550
- headers: await this.getV3AgentHeaders(),
4551
- });
4552
- if (!response.ok) {
4553
- errors.push(`${baseUrl}: ${response.status}`);
4554
- continue;
4555
- }
4556
- return await response.json();
4557
- }
4558
- catch (error) {
4559
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4560
- }
4561
- }
4562
- throw new Error(`Unable to cancel background job: ${errors.join('; ')}`);
4563
- }
4564
- async resolveV3BackgroundApproval(jobId, approvalId, approved, allowMode = 'once') {
4565
- await this.ensureV3ServiceKey();
4566
- const errors = [];
4567
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4568
- try {
4569
- const response = await fetch(this.getV3AgentApprovalUrl(baseUrl), {
4570
- method: 'POST',
4571
- headers: await this.getV3AgentHeaders(),
4572
- body: JSON.stringify({
4573
- context_id: jobId,
4574
- approval_id: approvalId,
4575
- approved,
4576
- allow_mode: allowMode,
4577
- }),
4578
- });
4579
- if (!response.ok) {
4580
- errors.push(`${baseUrl}: ${response.status}`);
4581
- continue;
4582
- }
4583
- return await response.json();
4584
- }
4585
- catch (error) {
4586
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4587
- }
4588
- }
4589
- throw new Error(`Unable to resolve background approval: ${errors.join('; ')}`);
4590
- }
4591
- async getV3BackgroundJobFiles(jobId) {
4592
- await this.ensureV3ServiceKey();
4593
- const errors = [];
4594
- for (const baseUrl of this.getV3AgentBaseUrls(false)) {
4595
- try {
4596
- const response = await fetch(this.getV3AgentBackgroundUrl(baseUrl, `${encodeURIComponent(jobId)}/files`), {
4597
- method: 'GET',
4598
- headers: await this.getV3AgentHeaders(),
4599
- });
4600
- if (!response.ok) {
4601
- errors.push(`${baseUrl}: ${response.status}`);
4602
- continue;
4603
- }
4604
- return await response.json();
4605
- }
4606
- catch (error) {
4607
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4608
- }
4609
- }
4610
- throw new Error(`Unable to fetch background job files: ${errors.join('; ')}`);
4611
- }
4612
- async applyV3BackgroundJobFiles(jobId, context = {}) {
4613
- const [data, job] = await Promise.all([
4614
- this.getV3BackgroundJobFiles(jobId),
4615
- this.getV3BackgroundJob(jobId),
4616
- ]);
4617
- if (job?.status !== 'completed' || job?.result?.success === false || job?.result?.type === 'error') {
4618
- throw new Error(`Background job ${jobId} is not a successful completed job and cannot be applied.`);
4619
- }
4620
- const mutatedPaths = new Set();
4621
- const addMutatedPath = (value) => {
4622
- const normalized = this.normalizeWorkspaceRelativePath(String(value || ''));
4623
- if (!normalized || path.isAbsolute(normalized) || normalized.split('/').includes('..'))
4624
- return;
4625
- mutatedPaths.add(normalized);
4626
- };
4627
- for (const filePath of (data.confirmed_mutations || []))
4628
- addMutatedPath(filePath);
4629
- for (const event of (Array.isArray(job?.events) ? job.events : [])) {
4630
- if (event?.type === 'file_mutation' && event?.action !== 'delete')
4631
- addMutatedPath(event.path);
4632
- if (event?.type === 'executor_complete') {
4633
- let summary = event.summary;
4634
- if (typeof summary === 'string') {
4635
- try {
4636
- summary = JSON.parse(summary);
4637
- }
4638
- catch {
4639
- summary = null;
4640
- }
4641
- }
4642
- if (summary?.status === 'completed') {
4643
- for (const filePath of [...(summary.changed_files || []), ...(summary.created_files || [])]) {
4644
- addMutatedPath(filePath);
4645
- }
4646
- }
4647
- }
4648
- }
4649
- if (mutatedPaths.size === 0) {
4650
- throw new Error(`Background job ${jobId} has no confirmed successful file mutations to apply.`);
4651
- }
4652
- const rootPath = this.resolveAgentTargetPath({
4653
- ...context,
4654
- workspacePath: context.workspacePath || data.local_workspace_path,
4655
- projectPath: context.projectPath || data.local_workspace_path,
4656
- targetPath: context.targetPath || data.local_workspace_path,
4657
- });
4658
- if (!rootPath) {
4659
- throw new Error('No local workspace path available for applying background job files.');
4660
- }
4661
- const applied = [];
4662
- const skipped = [];
4663
- for (const [relativePath, content] of Object.entries(data.files || {})) {
4664
- const normalizedRelativePath = this.normalizeWorkspaceRelativePath(relativePath);
4665
- if (!mutatedPaths.has(normalizedRelativePath)) {
4666
- skipped.push(relativePath);
4667
- continue;
4668
- }
4669
- if (typeof content !== 'string') {
4670
- skipped.push(relativePath);
4671
- continue;
4672
- }
4673
- if (this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath)) {
4674
- applied.push(relativePath);
4675
- }
4676
- else {
4677
- skipped.push(relativePath);
4678
- }
4679
- }
4680
- return { applied, skipped, status: data.status };
4681
- }
4682
- formatOperatorResponse(data = {}) {
4683
- // If the server returned a direct answer field, prefer it (for lookup tasks)
4684
- if (typeof data.answer === 'string' && data.answer.trim()) {
4685
- return data.answer.trim();
4686
- }
4687
- if (typeof data.result === 'string' && data.result.trim()) {
4688
- return data.result.trim();
4689
- }
4690
- const lines = [];
4691
- if (data.summary) {
4692
- lines.push(String(data.summary).trim());
4693
- }
4694
- if (Array.isArray(data.completed_agents) && data.completed_agents.length > 0) {
4695
- lines.push(`Completed agents: ${data.completed_agents.join(', ')}`);
4696
- }
4697
- if (data.details) {
4698
- const detailText = JSON.stringify(data.details, null, 2);
4699
- if (detailText && detailText !== '{}') {
4700
- lines.push(detailText.length > 12000 ? `${detailText.slice(0, 12000)}\n\n[Operator output truncated]` : detailText);
4701
- }
4702
- }
4703
- return lines.filter(Boolean).join('\n\n').trim() || 'Operator workflow completed.';
4704
- }
4705
- async runOperatorWorkflow(message, context = {}) {
4706
- const executionContext = await this.bindExecutionContext(context);
4707
- const requestedOperatorTimeoutMs = Number(context.operatorTimeoutMs ?? DEFAULT_OPERATOR_TIMEOUT_MS);
4708
- const timeoutMs = Number.isFinite(requestedOperatorTimeoutMs) && requestedOperatorTimeoutMs > 0
4709
- ? requestedOperatorTimeoutMs
4710
- : 0;
4711
- const errors = [];
4712
- const authToken = this.config.get('authToken');
4713
- // Collect a lightweight workspace file listing so the operator can
4714
- // ground its analysis in real files rather than returning files_analyzed: 0.
4715
- const workspacePath = executionContext.workspacePath || executionContext.projectPath || executionContext.targetPath || process.cwd();
4716
- const workspaceSummary = this.buildLocalWorkspaceSummary(workspacePath);
4717
- const requestedModel = String(executionContext.model || 'code');
4718
- const resolvedModel = this.resolveModelId(requestedModel);
4719
- for (const baseUrl of this.getOperatorBaseUrls()) {
4720
- const controller = new AbortController();
4721
- const timeoutId = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
4722
- try {
4723
- const response = await fetch(this.getOperatorStreamUrl(baseUrl), {
4724
- method: 'POST',
4725
- headers: {
4726
- 'Content-Type': 'application/json',
4727
- Accept: 'text/event-stream',
4728
- ...(executionContext.mcpContextId ? { 'X-MCP-Context-Id': String(executionContext.mcpContextId) } : {}),
4729
- ...(authToken ? {
4730
- Authorization: `Bearer ${authToken}`,
4731
- Cookie: `vigthoria-auth-token=${authToken}`,
4732
- } : {}),
4733
- },
4734
- body: JSON.stringify({
4735
- prompt: message,
4736
- task: message,
4737
- raw_prompt: executionContext.rawPrompt || null,
4738
- model: requestedModel,
4739
- preferred_model: resolvedModel,
4740
- context_id: executionContext.contextId,
4741
- trace_id: executionContext.traceId,
4742
- mcp_context_id: executionContext.mcpContextId || null,
4743
- context: {
4744
- workspace: { path: workspacePath },
4745
- workspace_path: workspacePath,
4746
- workspace_summary: workspaceSummary,
4747
- model: resolvedModel,
4748
- history: executionContext.history || [],
4749
- executionSurface: executionContext.executionSurface || 'cli',
4750
- clientSurface: executionContext.clientSurface || 'cli',
4751
- contextId: executionContext.contextId,
4752
- traceId: executionContext.traceId,
4753
- mcpContextId: executionContext.mcpContextId || null,
4754
- mcp_context_id: executionContext.mcpContextId || null,
4755
- rawPrompt: executionContext.rawPrompt || null,
4756
- requestStartedAt: executionContext.requestStartedAt,
4757
- vigthoriaBrain: executionContext.vigthoriaBrain || null,
4758
- vigthoria_brain: executionContext.vigthoriaBrain || null,
4759
- },
4760
- workflow_type: executionContext.workflowType || 'full',
4761
- options: {
4762
- stream: true,
4763
- save_to_vigflow: executionContext.savePlanToVigFlow === true,
4764
- },
4765
- }),
4766
- signal: controller.signal,
4767
- });
4768
- if (!response.ok) {
4769
- const errorText = await response.text().catch(() => '');
4770
- throw new Error(`Operator stream ${response.status}: ${sanitizeUserFacingErrorText(errorText).slice(0, 200)}`);
4771
- }
4772
- if (!response.body || typeof response.body.getReader !== 'function') {
4773
- const fallbackData = await response.json();
4774
- return {
4775
- content: this.formatOperatorResponse(fallbackData),
4776
- workflowId: fallbackData.workflow_id || null,
4777
- contextId: fallbackData.context_id || response.headers.get('x-context-id') || executionContext.contextId || null,
4778
- backendUrl: baseUrl,
4779
- savedWorkflow: fallbackData.saved_workflow || null,
4780
- metadata: {
4781
- source: 'operator',
4782
- mode: 'operator',
4783
- contextId: fallbackData.context_id || response.headers.get('x-context-id') || executionContext.contextId || null,
4784
- mcpContextId: fallbackData.mcp_context_id || response.headers.get('x-mcp-context-id') || executionContext.mcpContextId || null,
4785
- },
4786
- };
4787
- }
4788
- const reader = response.body.getReader();
4789
- const decoder = new TextDecoder();
4790
- let buffer = '';
4791
- let workflowId = null;
4792
- let contextId = response.headers.get('x-context-id') || executionContext.contextId || null;
4793
- let mcpContextId = response.headers.get('x-mcp-context-id') || executionContext.mcpContextId || null;
4794
- let resultPayload = null;
4795
- let savedWorkflow = null;
4796
- while (true) {
4797
- const { done, value } = await reader.read();
4798
- if (done) {
4799
- break;
4800
- }
4801
- buffer += decoder.decode(value, { stream: true });
4802
- const chunks = buffer.split('\n\n');
4803
- buffer = chunks.pop() || '';
4804
- for (const chunk of chunks) {
4805
- const lines = chunk.split('\n');
4806
- let eventName = 'message';
4807
- const dataLines = [];
4808
- for (const line of lines) {
4809
- if (line.startsWith('event:')) {
4810
- eventName = line.slice(6).trim();
4811
- }
4812
- else if (line.startsWith('data:')) {
4813
- dataLines.push(line.slice(5).trim());
4814
- }
4815
- }
4816
- if (dataLines.length === 0) {
4817
- continue;
4818
- }
4819
- let payload = null;
4820
- try {
4821
- payload = JSON.parse(dataLines.join('\n'));
4822
- }
4823
- catch {
4824
- payload = { raw: dataLines.join('\n') };
4825
- }
4826
- if (payload.workflow_id && !workflowId) {
4827
- workflowId = payload.workflow_id;
4828
- }
4829
- if (!contextId && typeof payload.context_id === 'string' && payload.context_id.trim()) {
4830
- contextId = payload.context_id.trim();
4831
- }
4832
- if (!mcpContextId && typeof payload.mcp_context_id === 'string' && payload.mcp_context_id.trim()) {
4833
- mcpContextId = payload.mcp_context_id.trim();
4834
- }
4835
- if (!savedWorkflow && payload.saved_workflow && typeof payload.saved_workflow === 'object') {
4836
- savedWorkflow = payload.saved_workflow;
4837
- }
4838
- if (typeof context.onStreamEvent === 'function') {
4839
- try {
4840
- context.onStreamEvent({ type: eventName, ...payload, context_id: payload.context_id || contextId, mcp_context_id: payload.mcp_context_id || mcpContextId });
4841
- }
4842
- catch {
4843
- // UI callback failures must not break operator execution.
4844
- }
4845
- }
4846
- if (eventName === 'result') {
4847
- resultPayload = payload;
4848
- }
4849
- if (eventName === 'error') {
4850
- throw new Error(payload.error || payload.message || 'Operator workflow failed');
4851
- }
4852
- }
4853
- }
4854
- return {
4855
- content: this.formatOperatorResponse(resultPayload || {}),
4856
- workflowId,
4857
- contextId,
4858
- backendUrl: baseUrl,
4859
- savedWorkflow,
4860
- metadata: { source: 'operator', mode: 'operator', contextId, mcpContextId, savedWorkflowId: savedWorkflow?.id || null },
4861
- };
4862
- }
4863
- catch (error) {
4864
- const isAbort = error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
4865
- if (isAbort) {
4866
- const mins = Math.round(timeoutMs / 60000);
4867
- throw new CLIError(`Operator workflow timed out after ${mins} minute(s). You can increase the timeout with VIGTHORIA_OPERATOR_TIMEOUT_MS.`, 'timeout', error);
4868
- }
4869
- errors.push(`${baseUrl}: ${error?.message || String(error)}`);
4870
- }
4871
- finally {
4872
- if (timeoutId)
4873
- clearTimeout(timeoutId);
4874
- }
4875
- }
4876
- throw new CLIError(`Operator workflow failed on all endpoints: ${errors.join(' | ')}`, 'model_backend');
4877
- }
4878
- /**
4879
- * Chat API - Direct Vigthoria Models API Architecture
4880
- *
4881
- * PRIMARY: api.vigthoria.io - Direct access to Vigthoria Model Router
4882
- * This is the centralized public API for all AI operations.
4883
- * Works for both authenticated and unauthenticated users.
4884
- *
4885
- * FALLBACK: Coder Cloud API - For authenticated users only
4886
- *
4887
- * NO localhost fallbacks - CLI is for external users, not server-side!
4888
- */
4889
- async chat(messages, model, useLocal = false) {
4890
- this.lastChatTransportErrors = [];
4891
- const resolvedModel = this.resolveModelId(model);
4892
- const candidateModels = this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()
4893
- ? [this.getSelfHostedFallbackModelId(resolvedModel, model)]
4894
- : [resolvedModel];
4895
- const fallbackModel = this.getFallbackModelId(resolvedModel);
4896
- if (fallbackModel && fallbackModel !== resolvedModel) {
4897
- candidateModels.push(fallbackModel);
4898
- }
4899
- for (const candidateModel of candidateModels) {
4900
- const response = await this.tryChatWithModel(messages, candidateModel, model);
4901
- if (response) {
4902
- if (candidateModel !== resolvedModel) {
4903
- this.logger.debug(`Recovered chat request using fallback model: ${candidateModel}`);
4904
- }
4905
- return response;
4906
- }
4907
- }
4908
- // Keep route diagnostics internal. Users only need to know the Vigthoria service is temporarily unavailable.
4909
- if (this.lastChatTransportErrors.length > 0) {
4910
- this.logger.debug(`Chat transport failures: ${this.lastChatTransportErrors.slice(0, 4).join(' | ')}`);
4911
- }
4912
- throw new CLIError(VIGTHORIA_SERVER_TEMPORARILY_UNAVAILABLE_MESSAGE, 'model_backend');
4913
- }
4914
- shouldSkipCloudRoutes(resolvedModel) {
4915
- return this.shouldSimulateCloudFailure() && this.isCloudModelId(resolvedModel);
4916
- }
4917
- async tryChatWithModel(messages, resolvedModel, requestedModel) {
4918
- const routeFailures = [];
4919
- const preferSelfHostedFirst = this.isSelfHostedPreferredModel(resolvedModel, requestedModel);
4920
- if (preferSelfHostedFirst) {
4921
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
4922
- if (selfHostedResponse) {
4923
- return selfHostedResponse;
4924
- }
4925
- }
4926
- // STRATEGY 1: Direct Vigthoria Models API (api.vigthoria.io)
4927
- // Cloud aliases must be charged through Coder wallet gates first.
4928
- if (!this.shouldSkipCloudRoutes(resolvedModel) && !this.isCloudModelId(resolvedModel)) {
4929
- try {
4930
- this.logger.debug(`Direct Vigthoria Models API: ${resolvedModel}`);
4931
- const token = this.getAccessToken();
4932
- const response = await this.modelRouterClient.post('/v1/chat/completions', {
4933
- model: resolvedModel,
4934
- messages,
4935
- max_tokens: this.config.get('preferences').maxTokens,
4936
- temperature: 0.7,
4937
- stream: false,
4938
- }, {
4939
- headers: token ? { Authorization: `Bearer ${token}` } : {},
4940
- });
4941
- if (response.data.choices && response.data.choices.length > 0) {
4942
- const content = response.data.choices[0].message?.content || response.data.choices[0].text;
4943
- if (typeof content !== 'string' || !content.trim()) {
4944
- this.logger.debug(`Direct API returned empty message content for ${resolvedModel}`);
4945
- }
4946
- else {
4947
- return {
4948
- id: response.data.id || `vigthoria-${Date.now()}`,
4949
- message: content,
4950
- model: response.data.model || resolvedModel || requestedModel,
4951
- usage: response.data.usage,
4952
- };
4953
- }
4954
- }
4955
- this.logger.debug(`Direct API returned no choices for ${resolvedModel}`);
4956
- }
4957
- catch (error) {
4958
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
4959
- this.logger.debug(`Direct Vigthoria Models API failed for ${resolvedModel}: ${errMsg}`);
4960
- routeFailures.push(`models:${String(errMsg).slice(0, 120)}`);
4961
- }
4962
- }
4963
- else {
4964
- if (this.isCloudModelId(resolvedModel)) {
4965
- this.logger.debug(`Cloud model ${resolvedModel} routed through Coder wallet gate first; skipping direct models API route.`);
4966
- }
4967
- else {
4968
- this.logger.debug(`Simulating cloud failure for ${resolvedModel}; skipping cloud-backed public API route.`);
4969
- }
4970
- }
4971
- // STRATEGY 2: Vigthoria Cloud API via Coder (authenticated users only)
4972
- if (this.config.isAuthenticated() && !this.shouldSkipCloudRoutes(resolvedModel)) {
4973
- try {
4974
- this.logger.debug(`Vigthoria Cloud API fallback: ${resolvedModel}`);
4975
- const response = await this.client.post('/api/ai/chat', {
4976
- messages,
4977
- model: resolvedModel,
4978
- maxTokens: this.config.get('preferences').maxTokens,
4979
- temperature: 0.7,
4980
- cloudConsent: this.isCloudModelId(resolvedModel),
4981
- });
4982
- if (response.data.success !== false) {
4983
- const content = response.data.response || response.data.message || response.data.content;
4984
- if (typeof content !== 'string' || !content.trim()) {
4985
- this.logger.debug(`Cloud API returned empty message content for ${resolvedModel}`);
4986
- }
4987
- else {
4988
- return {
4989
- id: response.data.id || `vigthoria-coder-${Date.now()}`,
4990
- message: content,
4991
- model: response.data.model || resolvedModel || requestedModel,
4992
- usage: response.data.usage,
4993
- };
4994
- }
4995
- }
4996
- this.logger.debug(`Cloud API returned success=false for ${resolvedModel}`);
4997
- }
4998
- catch (error) {
4999
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5000
- this.logger.debug(`Vigthoria Cloud API failed for ${resolvedModel}: ${errMsg}`);
5001
- routeFailures.push(`coder:${String(errMsg).slice(0, 120)}`);
5002
- }
5003
- try {
5004
- this.logger.debug(`Canonical Vigthoria Cloud fallback: ${resolvedModel}`);
5005
- const token = this.getAccessToken();
5006
- const response = await axios.post('https://coder.vigthoria.io/api/ai/chat', {
5007
- messages,
5008
- model: resolvedModel,
5009
- maxTokens: this.config.get('preferences').maxTokens,
5010
- temperature: 0.7,
5011
- cloudConsent: this.isCloudModelId(resolvedModel),
5012
- }, {
5013
- timeout: 180000,
5014
- httpsAgent: this._httpsAgent ?? undefined,
5015
- headers: token ? { Authorization: `Bearer ${token}`, Cookie: `vigthoria-auth-token=${token}` } : {},
5016
- });
5017
- if (response.data?.success !== false) {
5018
- const content = response.data.response || response.data.message || response.data.content;
5019
- if (typeof content === 'string' && content.trim()) {
5020
- return {
5021
- id: response.data.id || `vigthoria-coder-canonical-${Date.now()}`,
5022
- message: content,
5023
- model: response.data.model || resolvedModel || requestedModel,
5024
- usage: response.data.usage,
5025
- };
5026
- }
5027
- }
5028
- }
5029
- catch (error) {
5030
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5031
- this.logger.debug(`Canonical Vigthoria Cloud fallback failed for ${resolvedModel}: ${errMsg}`);
5032
- routeFailures.push(`coder-canonical:${String(errMsg).slice(0, 120)}`);
5033
- }
5034
- }
5035
- if (!preferSelfHostedFirst) {
5036
- const selfHostedResponse = await this.trySelfHostedChatWithModel(messages, resolvedModel, requestedModel);
5037
- if (selfHostedResponse) {
5038
- return selfHostedResponse;
5039
- }
5040
- }
5041
- if (routeFailures.length > 0) {
5042
- this.lastChatTransportErrors = routeFailures;
5043
- }
5044
- return null;
5045
- }
5046
- async trySelfHostedChatWithModel(messages, resolvedModel, requestedModel) {
5047
- if (!this.selfHostedModelRouterClient || !this.shouldTrySelfHostedFallback(resolvedModel, requestedModel)) {
5048
- return null;
5049
- }
5050
- const selfHostedModel = this.getSelfHostedFallbackModelId(resolvedModel, requestedModel);
5051
- try {
5052
- this.logger.debug(`Dedicated Vigthoria model endpoint fallback: ${selfHostedModel}`);
5053
- const response = await this.selfHostedModelRouterClient.post('/v1/chat/completions', {
5054
- model: selfHostedModel,
5055
- messages,
5056
- max_tokens: this.config.get('preferences').maxTokens,
5057
- temperature: 0.7,
5058
- stream: false,
5059
- }, {
5060
- headers: {
5061
- 'x-agent-mode': 'true',
5062
- },
5063
- });
5064
- if (response.data.choices && response.data.choices.length > 0) {
5065
- const content = response.data.choices[0].message?.content || response.data.choices[0].text;
5066
- if (typeof content !== 'string' || !content.trim()) {
5067
- this.logger.debug(`Dedicated Vigthoria model endpoint returned empty message content for ${selfHostedModel}`);
5068
- return null;
5069
- }
5070
- return {
5071
- id: response.data.id || `vigthoria-dedicated-${Date.now()}`,
5072
- message: content,
5073
- model: response.data.model || selfHostedModel,
5074
- usage: response.data.usage,
5075
- };
5076
- }
5077
- this.logger.debug(`Dedicated Vigthoria model endpoint returned no choices for ${selfHostedModel}`);
5078
- }
5079
- catch (error) {
5080
- const errMsg = error.response?.data?.error || error.message || 'Unknown error';
5081
- this.logger.debug(`Dedicated Vigthoria model endpoint fallback failed for ${selfHostedModel}: ${errMsg}`);
5082
- }
5083
- return null;
5084
- }
5085
- getFallbackModelId(resolvedModel) {
5086
- const cloudModels = new Set([
5087
- 'deepseek-v3.1:671b-cloud',
5088
- 'moonshotai/kimi-k2.5',
5089
- 'vigthoria-cloud-pro',
5090
- 'vigthoria-cloud-k2',
5091
- 'vigthoria-cloud-ultra',
5092
- ]);
5093
- if (cloudModels.has(resolvedModel)) {
5094
- return 'Vigthoria-v4-Code-27B';
5095
- }
5096
- return null;
5097
- }
5098
- isCloudModelId(resolvedModel) {
5099
- return resolvedModel === 'deepseek-v3.1:671b-cloud'
5100
- || resolvedModel === 'moonshotai/kimi-k2.5'
5101
- || resolvedModel === 'vigthoria-cloud-pro'
5102
- || resolvedModel === 'vigthoria-cloud-k2'
5103
- || resolvedModel === 'vigthoria-cloud-ultra';
5104
- }
5105
- canUseCloudModel() {
5106
- return this.config.hasCloudAccess();
5107
- }
5108
- resolvePermittedModelId(shortName) {
5109
- const normalizedRequested = String(shortName || '').trim().toLowerCase();
5110
- const blockedModels = new Set(['mini', 'creative-v3', 'creative-v4']);
5111
- if (blockedModels.has(normalizedRequested)) {
5112
- this.logger.debug(`Blocked retired model ${shortName}; using fallback Vigthoria-v4-Code-27B`);
5113
- return 'Vigthoria-v4-Code-27B';
5114
- }
5115
- const resolvedModel = this.resolveModelId(shortName);
5116
- if (this.isCloudModelId(resolvedModel) && !this.canUseCloudModel()) {
5117
- const fallbackModel = this.getSelfHostedFallbackModelId(resolvedModel, shortName);
5118
- this.logger.debug(`Blocked unauthorized cloud model ${shortName}; using fallback ${fallbackModel}`);
5119
- return fallbackModel;
5120
- }
5121
- return resolvedModel;
5122
- }
5123
- shouldSimulateCloudFailure() {
5124
- return process.env.VIGTHORIA_SIMULATE_CLOUD_FAILURE === '1';
5125
- }
5126
- shouldTrySelfHostedFallback(resolvedModel, requestedModel) {
5127
- if (!this.selfHostedModelRouterClient) {
5128
- return false;
5129
- }
5130
- const normalizedRequested = String(requestedModel || '').toLowerCase();
5131
- return this.isSelfHostedPreferredModel(resolvedModel, requestedModel)
5132
- || normalizedRequested === 'cloud'
5133
- || normalizedRequested === 'cloud-reason'
5134
- || normalizedRequested === 'ultra';
5135
- }
5136
- isSelfHostedPreferredModel(resolvedModel, requestedModel) {
5137
- const normalizedRequested = String(requestedModel || '').toLowerCase();
5138
- const selfHostedModels = new Set([
5139
- 'Vigthoria-v4-Creative-27B',
5140
- 'Vigthoria-v4-Code-27B',
5141
- 'Vigthoria-v4-Assistant-9B',
5142
- 'vigthoria-v3-code-35b',
5143
- 'vigthoria-v3-code-35b:latest',
5144
- 'vigthoria-v3-code-9b',
5145
- 'vigthoria-v3-code-9b:latest',
5146
- 'vigthoria-v3-balanced-4b',
5147
- 'vigthoria-v3-balanced-4b:latest',
5148
- 'qwen3-coder:latest',
5149
- ]);
5150
- return selfHostedModels.has(resolvedModel)
5151
- || normalizedRequested === 'agent'
5152
- || normalizedRequested === 'architect'
5153
- || normalizedRequested === 'assistant'
5154
- || normalizedRequested === 'code'
5155
- || normalizedRequested === 'code-30b'
5156
- || normalizedRequested === 'code-35b'
5157
- || normalizedRequested === 'code-9b'
5158
- || normalizedRequested === 'balanced'
5159
- || normalizedRequested === 'balanced-4b'
5160
- || normalizedRequested === 'pro';
5161
- }
5162
- getSelfHostedFallbackModelId(resolvedModel, requestedModel) {
5163
- if (this.isSelfHostedPreferredModel(resolvedModel, requestedModel)) {
5164
- return resolvedModel === 'qwen3-coder:latest' ? 'Vigthoria-v4-Code-27B' : resolvedModel;
5165
- }
5166
- return 'Vigthoria-v4-Code-27B';
5167
- }
5168
- // Streaming chat
5169
- async *chatStream(messages, model) {
5170
- const wsUrl = this.config.get('wsUrl');
5171
- const token = this.config.get('authToken');
5172
- return new Promise((resolve, reject) => {
5173
- const ws = new WebSocket(`${wsUrl}/chat`, {
5174
- headers: { Authorization: `Bearer ${token}` },
5175
- });
5176
- ws.on('open', () => {
5177
- ws.send(JSON.stringify({
5178
- type: 'chat',
5179
- messages,
5180
- model: this.resolvePermittedModelId(model),
5181
- stream: true,
5182
- }));
5183
- });
5184
- ws.on('message', (data) => {
5185
- const chunk = JSON.parse(data.toString());
5186
- // Yield chunk - but this pattern won't work directly
5187
- // Need to use event emitter pattern instead
5188
- });
5189
- ws.on('error', (error) => {
5190
- reject(error);
5191
- });
5192
- ws.on('close', () => {
5193
- resolve(undefined);
5194
- });
5195
- });
5196
- }
5197
- // Non-streaming alternative with callback
5198
- async chatWithCallback(messages, model, onChunk, onDone, onError) {
5199
- const wsUrl = this.config.get('wsUrl');
5200
- const token = this.config.get('authToken');
5201
- return new Promise((resolve, reject) => {
5202
- const ws = new WebSocket(`${wsUrl}/chat`, {
5203
- headers: { Authorization: `Bearer ${token}` },
5204
- });
5205
- ws.on('open', () => {
5206
- ws.send(JSON.stringify({
5207
- type: 'chat',
5208
- messages,
5209
- model: this.resolvePermittedModelId(model),
5210
- stream: true,
5211
- }));
5212
- });
5213
- ws.on('message', (data) => {
5214
- try {
5215
- const chunk = JSON.parse(data.toString());
5216
- if (chunk.type === 'content') {
5217
- onChunk(chunk.content);
5218
- }
5219
- else if (chunk.type === 'done') {
5220
- onDone();
5221
- ws.close();
5222
- resolve();
5223
- }
5224
- else if (chunk.type === 'error') {
5225
- onError(new Error(chunk.error));
5226
- ws.close();
5227
- reject(new Error(chunk.error));
5228
- }
5229
- }
5230
- catch (e) {
5231
- // Raw text chunk
5232
- onChunk(data.toString());
5233
- }
5234
- });
5235
- ws.on('error', (error) => {
5236
- onError(error);
5237
- reject(error);
5238
- });
5239
- ws.on('close', () => {
5240
- resolve();
5241
- });
5242
- });
5243
- }
5244
- // ─── Chat completion helper ────────────────────────────────────────
5245
- // Routes all AI file commands through the Model Router
5246
- // (/v1/chat/completions on api.vigthoria.io) which is the only
5247
- // backend that reliably accepts our auth token.
5248
- async chatComplete(systemPrompt, userPrompt, model, maxTokens) {
5249
- const resolvedModel = model ? this.resolvePermittedModelId(model) : 'Vigthoria-v4-Code-27B';
5250
- const response = await this.modelRouterClient.post('/v1/chat/completions', {
5251
- model: resolvedModel,
5252
- messages: [
5253
- { role: 'system', content: systemPrompt },
5254
- { role: 'user', content: userPrompt },
5255
- ],
5256
- max_tokens: maxTokens || this.config.get('preferences').maxTokens || 4096,
5257
- temperature: 0.3,
5258
- stream: false,
5259
- });
5260
- const content = response.data.choices?.[0]?.message?.content || response.data.choices?.[0]?.text || '';
5261
- return typeof content === 'string' ? content : '';
5262
- }
5263
- // Code operations - Using Vigthoria Centralized API (via Model Router)
5264
- async generateCode(prompt, language, model) {
5265
- const isNonHtmlLang = !['html', 'css'].includes(language.toLowerCase());
5266
- const wordCount = prompt.trim().split(/\s+/).length;
5267
- // Prepend a forceful scope-enforcement instruction so the model
5268
- // doesn't expand a small task into an oversized glossy page.
5269
- const buildScopedPrompt = (retry) => {
5270
- if (retry && isNonHtmlLang) {
5271
- // Ultra-minimal retry prompt — no long instruction block
5272
- 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}`;
5273
- }
5274
- const lines = [
5275
- 'IMPORTANT — MANDATORY SCOPE CONSTRAINTS (violation = failure):',
5276
- `1. You MUST output ONLY ${language.toUpperCase()} code. Do NOT output HTML, CSS, or DOM manipulation unless the language is html/css.`,
5277
- `2. The target language is: ${language}. Do not switch to a different language.`,
5278
- '3. Output ONLY what the user explicitly asked for. Nothing more.',
5279
- '4. If the prompt is ≤ 15 words, produce ≤ 80 lines of code maximum.',
5280
- '5. If the user says "tiny", "small", "simple", "minimal", or "basic", produce ≤ 50 lines.',
5281
- '6. NEVER add any of these unless the user EXPLICITLY requests them:',
5282
- ' - Hero sections, CTAs, testimonials, pricing tables, footers, navbars',
5283
- ' - CSS animations, gradients, neon effects, glass-morphism, particles',
5284
- ' - Google Fonts, Font Awesome, external CDN links, icon libraries',
5285
- ' - DOM manipulation, document.createElement, innerHTML',
5286
- ' - Responsive breakpoints, media queries (unless asked)',
5287
- ' - Multiple pages or components when one was requested',
5288
- ' - Input validation, error handling beyond what was requested',
5289
- '7. Prefer inline styles or a small <style> block ONLY if the language is html. No CSS frameworks.',
5290
- '8. Return raw code only — no markdown fences, no explanations, no comments about what could be added.',
5291
- '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.',
5292
- ];
5293
- if (isNonHtmlLang) {
5294
- 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.`);
5295
- }
5296
- lines.push('', prompt);
5297
- return lines.join('\n');
5298
- };
5299
- // First attempt — route through Model Router chat completions
5300
- const systemPrompt = `You are a code generator. Output ONLY raw ${language} code. No markdown fences, no explanations, no commentary. Just the code.`;
5301
- let code = await this.chatComplete(systemPrompt, buildScopedPrompt(false), model);
5302
- // Strip markdown fences if model included them
5303
- code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5304
- // Client-side validation: reject DOM-polluted or over-engineered responses for non-HTML languages
5305
- const needsRetry = isNonHtmlLang && (this.codeContainsDomPollution(code) ||
5306
- this.codeIsOverEngineered(code, prompt));
5307
- if (needsRetry) {
5308
- // Retry once with stronger constraint — via Model Router
5309
- code = await this.chatComplete(systemPrompt, buildScopedPrompt(true), model);
5310
- code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5311
- // If still polluted, strip DOM code client-side
5312
- if (this.codeContainsDomPollution(code)) {
5313
- code = this.stripDomPollution(code, language);
5314
- }
5315
- }
5316
- // Final cleanup: for non-HTML, if still over-engineered after retry,
5317
- // extract only the first complete function/class definition.
5318
- if (isNonHtmlLang && this.codeIsOverEngineered(code, prompt)) {
5319
- code = this.extractFirstFunction(code);
5320
- }
5321
- // Ensure balanced braces — model sometimes truncates closing braces
5322
- code = this.ensureBalancedBraces(code);
5323
- return code;
5324
- }
5325
- /**
5326
- * Ensure code has balanced curly braces by appending missing closing braces.
5327
- */
5328
- ensureBalancedBraces(code) {
5329
- // Count braces/parens/brackets outside strings and comments
5330
- let braces = 0, parens = 0, brackets = 0;
5331
- let inStr = null;
5332
- let inLine = false, inBlock = false;
5333
- for (let i = 0; i < code.length; i++) {
5334
- const ch = code[i], nx = code[i + 1] || '';
5335
- if (inLine) {
5336
- if (ch === '\n')
5337
- inLine = false;
5338
- continue;
5339
- }
5340
- if (inBlock) {
5341
- if (ch === '*' && nx === '/') {
5342
- inBlock = false;
5343
- i++;
5344
- }
5345
- continue;
5346
- }
5347
- if (inStr) {
5348
- if (ch === inStr && code[i - 1] !== '\\')
5349
- inStr = null;
5350
- continue;
5351
- }
5352
- if (ch === '/' && nx === '/') {
5353
- inLine = true;
5354
- continue;
5355
- }
5356
- if (ch === '/' && nx === '*') {
5357
- inBlock = true;
5358
- continue;
5359
- }
5360
- if (ch === '"' || ch === "'" || ch === '`') {
5361
- inStr = ch;
5362
- continue;
5363
- }
5364
- if (ch === '{')
5365
- braces++;
5366
- else if (ch === '}')
5367
- braces--;
5368
- else if (ch === '(')
5369
- parens++;
5370
- else if (ch === ')')
5371
- parens--;
5372
- else if (ch === '[')
5373
- brackets++;
5374
- else if (ch === ']')
5375
- brackets--;
5376
- }
5377
- let result = code.trimEnd();
5378
- for (let i = 0; i < braces; i++)
5379
- result += '\n}';
5380
- for (let i = 0; i < parens; i++)
5381
- result += ')';
5382
- for (let i = 0; i < brackets; i++)
5383
- result += ']';
5384
- return braces > 0 || parens > 0 || brackets > 0 ? result : code;
5385
- }
5386
- /**
5387
- * Quick JS/TS syntax validation using Node's built-in parser.
5388
- * Returns true if the code parses without errors.
5389
- */
5390
- validateJsSyntax(code) {
5391
- try {
5392
- // Use Function constructor to check syntax without executing
5393
- new Function(code);
5394
- return true;
5395
- }
5396
- catch {
5397
- return false;
5398
- }
5399
- }
5400
- /**
5401
- * Extract the first complete function/class from code.
5402
- * Used as last-resort when the model keeps over-producing.
5403
- */
5404
- extractFirstFunction(code) {
5405
- const lines = code.split('\n');
5406
- 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()));
5407
- if (funcStart === -1)
5408
- return code;
5409
- let braceDepth = 0;
5410
- let foundOpen = false;
5411
- const result = [];
5412
- for (let i = funcStart; i < lines.length; i++) {
5413
- const line = lines[i];
5414
- result.push(line);
5415
- braceDepth += (line.match(/{/g) || []).length;
5416
- braceDepth -= (line.match(/}/g) || []).length;
5417
- if (braceDepth > 0)
5418
- foundOpen = true;
5419
- if (foundOpen && braceDepth <= 0) {
5420
- break;
5421
- }
5422
- }
5423
- // If we reached the end without balancing braces, add closing
5424
- if (braceDepth > 0) {
5425
- result.push('};');
5426
- }
5427
- return result.join('\n');
5428
- }
5429
- /**
5430
- * Detect if code is excessively over-engineered for a short prompt.
5431
- * E.g. a "multiply function" request producing 20+ lines.
5432
- */
5433
- codeIsOverEngineered(code, prompt) {
5434
- const wordCount = prompt.trim().split(/\s+/).length;
5435
- const lineCount = code.trim().split('\n').length;
5436
- // Short prompt (≤15 words) should not produce more than 15 lines
5437
- if (wordCount <= 15 && lineCount > 15)
5438
- return true;
5439
- return false;
5440
- }
5441
- /**
5442
- * Detect if generated code contains DOM/HTML pollution inappropriate
5443
- * for a pure programming language like JavaScript, Python, etc.
5444
- */
5445
- codeContainsDomPollution(code) {
5446
- 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*\(/;
5447
- return domPatterns.test(code);
5448
- }
5449
- /**
5450
- * Strip DOM pollution from generated code, keeping only the pure logic.
5451
- * Used as last-resort fallback when the model repeatedly ignores constraints.
5452
- */
5453
- stripDomPollution(code, language) {
5454
- const lines = code.split('\n');
5455
- const cleanLines = [];
5456
- let insideDomBlock = false;
5457
- let braceDepth = 0;
5458
- for (const line of lines) {
5459
- // Detect start of DOM blocks
5460
- if (/document\.|\.style\.|\.appendChild|\.removeChild|\.textContent\s*=|@keyframes|addEventListener|CSSAnimation|\.getAnimations|\.animate\s*\(/.test(line) && !insideDomBlock) {
5461
- insideDomBlock = true;
5462
- braceDepth = 0;
5463
- }
5464
- if (insideDomBlock) {
5465
- braceDepth += (line.match(/{/g) || []).length;
5466
- braceDepth -= (line.match(/}/g) || []).length;
5467
- if (braceDepth <= 0) {
5468
- insideDomBlock = false;
5469
- }
5470
- continue; // Skip DOM lines
5471
- }
5472
- cleanLines.push(line);
5473
- }
5474
- const cleaned = cleanLines.join('\n').trim();
5475
- // If we stripped too aggressively (less than 2 lines left), return a minimal stub
5476
- if (cleaned.split('\n').length < 2) {
5477
- return `// Auto-generated: model returned DOM code for a ${language} task.\n// Please regenerate or provide more specific instructions.`;
5478
- }
5479
- return cleaned;
5480
- }
5481
- // Senior Developer Mode - Planning + Generation + Quality Check
5482
- async generateProject(prompt, projectType, model) {
5483
- const sysPrompt = [
5484
- `You are a senior developer. Generate a complete ${projectType} project.`,
5485
- 'Return a JSON object with these fields:',
5486
- ' "code": the full source code as a string,',
5487
- ' "plan": an object describing the architecture,',
5488
- ' "quality": { "lineCount": number, "score": number (0-100) }',
5489
- 'Return ONLY the JSON object, no markdown fences.',
5490
- ].join('\n');
5491
- const raw = await this.chatComplete(sysPrompt, prompt, model, 8192);
5492
- try {
5493
- const cleaned = raw.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5494
- const parsed = JSON.parse(cleaned);
5495
- return {
5496
- code: parsed.code || raw,
5497
- plan: parsed.plan,
5498
- quality: parsed.quality,
5499
- };
5500
- }
5501
- catch {
5502
- return { code: raw.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim() };
5503
- }
5504
- }
5505
- async explainCode(code, language) {
5506
- const sysPrompt = [
5507
- `You are a code explainer. Explain the following ${language} code clearly and concisely.`,
5508
- 'Focus on what it does, how it works, and any notable patterns or potential issues.',
5509
- 'Format your response as clean Markdown:',
5510
- '- Use ## headers for major sections (e.g. ## Overview, ## How It Works, ## Key Details).',
5511
- '- Use bullet points (- or *) for all lists. Do NOT use numbered lists.',
5512
- '- Wrap code references in backticks.',
5513
- '- Keep paragraphs short (2-3 sentences max).',
5514
- '- Do NOT use raw HTML or excessive blank lines.',
5515
- '- Do NOT nest numbered lists inside sections.',
5516
- ].join('\n');
5517
- return this.chatComplete(sysPrompt, code);
5518
- }
5519
- async reviewCode(code, language) {
5520
- const sysPrompt = [
5521
- `You are a senior code reviewer for ${language}. Analyze the code and return a JSON object with:`,
5522
- ' "score": number 0-100,',
5523
- ' "issues": [{ "type": string, "line": number, "message": string, "severity": "error"|"warning"|"info" }],',
5524
- ' "suggestions": [string]',
5525
- 'Rules:',
5526
- '- Return concrete, line-specific issues with severity.',
5527
- '- Every issue MUST reference a line number.',
5528
- '- Report each distinct bug ONCE. Do NOT report the same bug multiple times with different wording.',
5529
- '- For trivial/short code (< 10 lines), report ONLY actual bugs. Do NOT pad with style, robustness, or best-practice suggestions.',
5530
- '- 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.',
5531
- '- Prioritize REAL BUGS: wrong operators, logic errors, off-by-one, type mismatches.',
5532
- '- Do NOT suggest adding error handling, input validation, or documentation as issues unless the user explicitly asked for a style review.',
5533
- '- Return ONLY the JSON object, no markdown fences or extra text.',
5534
- ].join('\n');
5535
- let raw = {};
5536
- try {
5537
- const result = await this.chatComplete(sysPrompt, code);
5538
- const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5539
- raw = JSON.parse(cleaned);
5540
- }
5541
- catch {
5542
- // If parsing fails, return minimal review
5543
- }
5544
- const score = typeof raw.score === 'number' ? raw.score : 0;
5545
- const issues = Array.isArray(raw.issues) ? raw.issues : [];
5546
- const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
5547
- // Merge client-side heuristics, but with tight dedup to avoid
5548
- // redundant over-reporting when the model already found the bug.
5549
- const modelFoundError = issues.some(i => i.severity === 'error');
5550
- const heuristic = this.heuristicCodeIssues(code, language);
5551
- for (const h of heuristic) {
5552
- // If the model already found a real error, skip non-error heuristics
5553
- // entirely — they're just padding (style, robustness, etc.)
5554
- if (modelFoundError && h.severity !== 'error')
5555
- continue;
5556
- // Semantic duplicate check: same line + (similar type OR overlapping
5557
- // keywords in the message). This catches cases where the model
5558
- // and heuristic describe the same bug with different wording.
5559
- const hWords = new Set(h.message.toLowerCase().split(/\W+/).filter(w => w.length > 3));
5560
- const hTypeNorm = h.type.toLowerCase().replace(/[^a-z]/g, '');
5561
- const isSemanticallyDuplicate = issues.some((existing) => {
5562
- if (existing.line !== h.line)
5563
- return false;
5564
- // Normalize types: "logic-error", "logic_error", "logic" all match
5565
- const eTypeNorm = existing.type.toLowerCase().replace(/[^a-z]/g, '');
5566
- if (eTypeNorm === hTypeNorm || eTypeNorm.startsWith(hTypeNorm) || hTypeNorm.startsWith(eTypeNorm))
5567
- return true;
5568
- // Both errors on same line about the same category of problem
5569
- if (existing.severity === 'error' && h.severity === 'error')
5570
- return true;
5571
- // Check keyword overlap — if ≥2 significant words match, it's the same finding
5572
- const eWords = existing.message.toLowerCase().split(/\W+/).filter(w => w.length > 3);
5573
- let overlap = 0;
5574
- for (const w of eWords) {
5575
- if (hWords.has(w))
5576
- overlap++;
5577
- }
5578
- return overlap >= 2;
5579
- });
5580
- if (!isSemanticallyDuplicate) {
5581
- issues.push(h);
5582
- }
5583
- }
5584
- // Sort: errors first, then warnings, then info
5585
- const severityOrder = { error: 0, warning: 1, info: 2 };
5586
- issues.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3));
5587
- // Prevent contradictory output: low score but zero issues.
5588
- if (score < 50 && issues.length === 0) {
5589
- issues.push({
5590
- type: 'quality',
5591
- line: 1,
5592
- 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.`,
5593
- severity: 'warning',
5594
- });
5595
- }
5596
- return { score, issues, suggestions };
3504
+ workspaceFilesOutOfBand: !!workspaceFiles,
3505
+ }),
3506
+ workspace_files: workspaceFiles,
3507
+ stream: false,
3508
+ model: resolvedModel,
3509
+ mcp_context_id: executionContext.mcpContextId || undefined,
3510
+ };
3511
+ return this.v3AgentClient.requestBackground('POST', '', body);
5597
3512
  }
5598
- /**
5599
- * Lightweight client-side heuristic scan: catches common code smells
5600
- * AND logic/arithmetic bugs so review never returns "score 30, no issues".
5601
- */
5602
- heuristicCodeIssues(code, language) {
5603
- const issues = [];
5604
- const lines = code.split('\n');
5605
- for (let i = 0; i < lines.length; i++) {
5606
- const line = lines[i];
5607
- const lineNum = i + 1;
5608
- // console.log left in production code
5609
- if (/\bconsole\.(log|debug|info)\b/.test(line) && !/\/\//.test(line.slice(0, line.indexOf('console')))) {
5610
- issues.push({ type: 'quality', line: lineNum, message: 'console.log/debug statement — remove or replace with proper logging.', severity: 'warning' });
5611
- }
5612
- // TODO/FIXME/HACK comments
5613
- if (/\b(TODO|FIXME|HACK|XXX)\b/.test(line)) {
5614
- issues.push({ type: 'maintainability', line: lineNum, message: `Unresolved ${line.match(/\b(TODO|FIXME|HACK|XXX)\b/)?.[0]} comment.`, severity: 'info' });
5615
- }
5616
- // Empty catch blocks
5617
- if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
5618
- issues.push({ type: 'error-handling', line: lineNum, message: 'Empty catch block — errors are silently swallowed.', severity: 'warning' });
5619
- }
5620
- // Very long lines (> 200 chars)
5621
- if (line.length > 200) {
5622
- issues.push({ type: 'style', line: lineNum, message: `Line exceeds 200 characters (${line.length}).`, severity: 'info' });
5623
- }
5624
- // Arithmetic / logic bugs: function named 'add' but using subtraction
5625
- if (/\breturn\s+\w+\s*-\s*\w+/.test(line)) {
5626
- // Check if the enclosing function name implies addition
5627
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5628
- if (/function\s+add\b/i.test(lines[j]) || /\badd\s*[=(]/.test(lines[j])) {
5629
- issues.push({ type: 'logic', line: lineNum, message: 'Subtraction operator in a function named "add" — likely should be addition (+).', severity: 'error' });
5630
- break;
5631
- }
5632
- }
5633
- }
5634
- // Multiplication where addition is expected
5635
- if (/\breturn\s+\w+\s*\*\s*\w+/.test(line)) {
5636
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5637
- if (/function\s+add\b/i.test(lines[j]) || /\badd\s*[=(]/.test(lines[j])) {
5638
- issues.push({ type: 'logic', line: lineNum, message: 'Multiplication operator in a function named "add" — likely should be addition (+).', severity: 'error' });
5639
- break;
5640
- }
5641
- }
5642
- }
5643
- // Division where subtraction/addition is expected in subtract
5644
- if (/\breturn\s+\w+\s*\+\s*\w+/.test(line)) {
5645
- for (let j = i; j >= Math.max(0, i - 5); j--) {
5646
- if (/function\s+subtract\b/i.test(lines[j]) || /\bsubtract\s*[=(]/.test(lines[j])) {
5647
- issues.push({ type: 'logic', line: lineNum, message: 'Addition operator in a function named "subtract" — likely should be subtraction (-).', severity: 'error' });
5648
- 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);
5649
3554
  }
5650
- }
5651
- }
5652
- // Accumulator/reducer bug: subtraction used in a reduce/total/sum context
5653
- // e.g. "sum - item.price" in a function named calculateTotal, getTotal, etc.
5654
- 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)) {
5655
- // Check enclosing function/method name for total/sum/aggregate semantics
5656
- for (let j = i; j >= Math.max(0, i - 10); j--) {
5657
- 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])) {
5658
- // Also check if this looks like an accumulator pattern (sum - something instead of sum + something)
5659
- if (/\w+\s*-\s*\w+\.\s*(price|cost|amount|value|total|quantity)\b/.test(line)) {
5660
- issues.push({ type: 'logic', line: lineNum, message: 'Subtraction used in an accumulation context (total/sum/reduce) — likely should be addition (+).', severity: 'error' });
5661
- }
5662
- break;
3555
+ catch {
3556
+ summary = null;
5663
3557
  }
5664
3558
  }
5665
- }
5666
- // Mismatched function name vs operator: sum/total function using wrong operator
5667
- if (/\breturn\b/.test(line)) {
5668
- for (let j = i; j >= Math.max(0, i - 8); j--) {
5669
- const funcMatch = lines[j].match(/function\s+(\w+)/i) || lines[j].match(/(?:const|let|var)\s+(\w+)\s*=/i);
5670
- if (funcMatch) {
5671
- const funcName = funcMatch[1].toLowerCase();
5672
- if ((funcName.includes('total') || funcName.includes('sum') || funcName.includes('add')) && /\s-\s/.test(line) && !/\s\+\s/.test(line)) {
5673
- const alreadyReported = issues.some(iss => iss.line === lineNum && iss.type === 'logic');
5674
- if (!alreadyReported) {
5675
- issues.push({ type: 'logic', line: lineNum, message: `Subtraction operator in "${funcMatch[1]}" — function name implies addition. Likely should use + instead of -.`, severity: 'error' });
5676
- }
5677
- }
5678
- break;
3559
+ if (summary?.status === 'completed') {
3560
+ for (const filePath of [...(summary.changed_files || []), ...(summary.created_files || [])]) {
3561
+ addMutatedPath(filePath);
5679
3562
  }
5680
3563
  }
5681
3564
  }
5682
- // Off-by-one: comparing with === where <= or >= may be needed
5683
- // (not implemented yet — would require more complex AST analysis)
5684
- // Limit to 15 heuristic issues
5685
- if (issues.length >= 15)
5686
- break;
5687
- }
5688
- return issues;
5689
- }
5690
- async fixCode(code, language, fixType) {
5691
- // Client-side syntax pre-check: detect obvious errors and include
5692
- // them in the request so the model has concrete signals.
5693
- const syntaxHints = fixType === 'bugs' || fixType === 'syntax' ? this.detectSyntaxErrors(code, language) : '';
5694
- // Run heuristic logic analysis, but ONLY pass hints relevant to the fixType
5695
- const heuristicIssues = this.heuristicCodeIssues(code, language);
5696
- const relevantIssues = heuristicIssues.filter(issue => {
5697
- if (fixType === 'bugs' || fixType === 'logic')
5698
- return issue.type === 'logic' || issue.severity === 'error';
5699
- if (fixType === 'syntax')
5700
- return issue.type !== 'logic' && issue.severity === 'error';
5701
- if (fixType === 'style')
5702
- return issue.type === 'style' || issue.type === 'quality';
5703
- if (fixType === 'security')
5704
- return issue.type === 'security';
5705
- if (fixType === 'performance')
5706
- return issue.type === 'performance';
5707
- return true; // 'all' or unknown fixType: include everything
5708
- });
5709
- const logicHints = relevantIssues
5710
- .map(i => `Line ${i.line}: [${i.type}] ${i.message}`)
5711
- .join('\n// ');
5712
- const allHints = [syntaxHints, logicHints].filter(Boolean).join('\n// ');
5713
- // Build a constraint preamble that's stricter for syntax-only mode.
5714
- let preamble;
5715
- if (fixType === 'syntax') {
5716
- preamble = allHints
5717
- ? `// 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`
5718
- : '';
5719
- }
5720
- else {
5721
- preamble = allHints
5722
- ? `// 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`
5723
- : '';
5724
- }
5725
- const augmentedCode = preamble ? `${preamble}${code}` : code;
5726
- const sysPrompt = [
5727
- `You are a ${language} code fixer. Fix the code for: ${fixType}.`,
5728
- 'Return a JSON object with:',
5729
- ' "fixed": the COMPLETE corrected source code as a string (not a snippet — the full file),',
5730
- ' "changes": [{ "line": number, "before": string, "after": string, "reason": string }]',
5731
- 'Rules:',
5732
- '- The "fixed" field MUST contain the entire corrected source code with ALL lines, including unchanged lines.',
5733
- '- The "fixed" code MUST have balanced braces, parentheses, and brackets.',
5734
- '- Fix ONLY the issues related to the fix type.',
5735
- '- Do not add comments, do not restructure beyond the minimal fix.',
5736
- '- Return ONLY the JSON object, no markdown fences.',
5737
- ].join('\n');
5738
- let raw = {};
5739
- try {
5740
- const result = await this.chatComplete(sysPrompt, augmentedCode);
5741
- const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
5742
- raw = JSON.parse(cleaned);
5743
- }
5744
- catch {
5745
- // If parsing fails, fall through to client-side handling
5746
- }
5747
- let fixed = typeof raw.fixed === 'string' ? raw.fixed : (typeof raw.code === 'string' ? raw.code : code);
5748
- let changes = Array.isArray(raw.changes) ? raw.changes : [];
5749
- // If server returned no changes but we found issues, strip
5750
- // our injected comment prefix from the returned code and attempt
5751
- // a basic client-side repair.
5752
- if (changes.length === 0 && preamble && fixed === augmentedCode) {
5753
- fixed = code; // restore original
5754
- }
5755
- // Strip the injected comment block if it leaked into the output
5756
- if (fixed.startsWith('// BUGS DETECTED BY STATIC ANALYSIS') || fixed.startsWith('// SYNTAX ERRORS DETECTED BY CLIENT')) {
5757
- const idx = fixed.indexOf('\n\n');
5758
- if (idx !== -1) {
5759
- fixed = fixed.slice(idx + 2);
5760
- }
5761
3565
  }
5762
- // For syntax-only fixes, strip any comments the model added that
5763
- // weren't in the original code (e.g. "// Fixed mismatched parentheses").
5764
- if (fixType === 'syntax' && fixed !== code) {
5765
- fixed = this.stripInjectedComments(code, fixed, language);
5766
- }
5767
- // Safety net: for syntax fixes, ensure the fix didn't make bracket
5768
- // balance worse. If the original had more closing delimiters than
5769
- // the fix, append the missing ones.
5770
- if (fixType === 'syntax' && fixed !== code) {
5771
- fixed = this.repairBracketBalance(code, fixed);
5772
- }
5773
- // Final bracket-balance guarantee — ensure the emitted code has
5774
- // balanced braces/parens/brackets regardless of what the model returned.
5775
- fixed = this.ensureBalancedBraces(fixed);
5776
- // For JS/TS syntax fixes, validate the output actually parses.
5777
- // If it doesn't, attempt a more aggressive bracket repair.
5778
- if ((fixType === 'syntax' || fixType === 'bugs') && fixed !== code) {
5779
- const lang = language.toLowerCase();
5780
- if (['javascript', 'js', 'typescript', 'ts'].includes(lang)) {
5781
- if (!this.validateJsSyntax(fixed)) {
5782
- // Try once more: strip any remaining injected comments and re-balance
5783
- let repaired = this.stripInjectedComments(code, fixed, language);
5784
- repaired = this.ensureBalancedBraces(repaired);
5785
- if (this.validateJsSyntax(repaired)) {
5786
- fixed = repaired;
5787
- }
5788
- // If still invalid, return the best-effort fix — better than
5789
- // silently reverting to the original broken code.
5790
- }
5791
- }
3566
+ if (mutatedPaths.size === 0) {
3567
+ throw new Error(`Background job ${jobId} has no confirmed successful file mutations to apply.`);
5792
3568
  }
5793
- // If there are still no changes but the fixed code differs, compute
5794
- // a semantic diff using LCS so inserted/removed lines don't cause
5795
- // every subsequent line to appear as changed.
5796
- if (changes.length === 0 && fixed !== code) {
5797
- // Use a clean reason string — strip verbose static-analysis hints
5798
- const cleanReason = relevantIssues.length > 0
5799
- ? relevantIssues.map(i => i.message).join('; ')
5800
- : 'AI-suggested fix';
5801
- 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.');
5802
3577
  }
5803
- return { fixed, changes };
5804
- }
5805
- /**
5806
- * Compute a semantic diff between original and fixed code using
5807
- * Longest Common Subsequence (LCS) to avoid the line-shift inflation
5808
- * bug where inserting one line flags all subsequent lines as changed.
5809
- */
5810
- computeSemanticDiff(original, fixed, reason) {
5811
- const origLines = original.split('\n');
5812
- const fixedLines = fixed.split('\n');
5813
- const changes = [];
5814
- // Build LCS table
5815
- const m = origLines.length;
5816
- const n = fixedLines.length;
5817
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
5818
- for (let i = 1; i <= m; i++) {
5819
- for (let j = 1; j <= n; j++) {
5820
- if (origLines[i - 1] === fixedLines[j - 1]) {
5821
- dp[i][j] = dp[i - 1][j - 1] + 1;
5822
- }
5823
- else {
5824
- dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
5825
- }
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;
5826
3585
  }
5827
- }
5828
- // Backtrack to find the diff
5829
- let i = m, j = n;
5830
- const ops = [];
5831
- while (i > 0 || j > 0) {
5832
- if (i > 0 && j > 0 && origLines[i - 1] === fixedLines[j - 1]) {
5833
- ops.push({ type: 'keep', origLine: i, fixedLine: j, text: origLines[i - 1] });
5834
- i--;
5835
- j--;
3586
+ if (typeof content !== 'string') {
3587
+ skipped.push(relativePath);
3588
+ continue;
5836
3589
  }
5837
- else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
5838
- ops.push({ type: 'add', fixedLine: j, text: fixedLines[j - 1] });
5839
- j--;
3590
+ if (this.writeV3AgentWorkspaceFile(rootPath, relativePath, content, rootPath)) {
3591
+ applied.push(relativePath);
5840
3592
  }
5841
3593
  else {
5842
- ops.push({ type: 'remove', origLine: i, text: origLines[i - 1] });
5843
- i--;
5844
- }
5845
- }
5846
- ops.reverse();
5847
- // Merge adjacent remove+add pairs into single change entries
5848
- let idx = 0;
5849
- while (idx < ops.length) {
5850
- const op = ops[idx];
5851
- if (op.type === 'remove') {
5852
- // Look ahead for a matching 'add' immediately after
5853
- if (idx + 1 < ops.length && ops[idx + 1].type === 'add') {
5854
- changes.push({
5855
- line: op.origLine,
5856
- before: op.text,
5857
- after: ops[idx + 1].text,
5858
- reason: reason || 'AI-suggested fix',
5859
- });
5860
- idx += 2;
5861
- continue;
5862
- }
5863
- changes.push({
5864
- line: op.origLine,
5865
- before: op.text,
5866
- after: '(line removed)',
5867
- reason: reason || 'AI-suggested fix',
5868
- });
5869
- }
5870
- else if (op.type === 'add') {
5871
- changes.push({
5872
- line: op.fixedLine,
5873
- before: '(new line)',
5874
- after: op.text,
5875
- reason: reason || 'AI-suggested fix',
5876
- });
3594
+ skipped.push(relativePath);
5877
3595
  }
5878
- idx++;
5879
- }
5880
- if (changes.length === 0) {
5881
- changes.push({ line: 1, before: '(whitespace changes)', after: '(see fixed file)', reason: reason || 'AI-suggested fix' });
5882
3596
  }
5883
- return changes;
3597
+ return { applied, skipped, status: data.status };
5884
3598
  }
5885
- /**
5886
- * Lightweight client-side syntax error detection.
5887
- * Returns a human-readable description of obvious errors, or empty string.
5888
- */
5889
- detectSyntaxErrors(code, language) {
5890
- const lang = language.toLowerCase();
5891
- const errors = [];
5892
- if (lang === 'javascript' || lang === 'typescript' || lang === 'js' || lang === 'ts') {
5893
- // Bracket matching
5894
- let braces = 0, brackets = 0, parens = 0;
5895
- // Track string context (simple heuristic — not a full parser)
5896
- let inString = null;
5897
- let inLineComment = false;
5898
- let inBlockComment = false;
5899
- for (let i = 0; i < code.length; i++) {
5900
- const ch = code[i];
5901
- const next = code[i + 1] || '';
5902
- if (inLineComment) {
5903
- if (ch === '\n')
5904
- inLineComment = false;
5905
- continue;
5906
- }
5907
- if (inBlockComment) {
5908
- if (ch === '*' && next === '/') {
5909
- inBlockComment = false;
5910
- i++;
5911
- }
5912
- continue;
5913
- }
5914
- if (inString) {
5915
- if (ch === inString && code[i - 1] !== '\\')
5916
- inString = null;
5917
- continue;
5918
- }
5919
- if (ch === '/' && next === '/') {
5920
- inLineComment = true;
5921
- continue;
5922
- }
5923
- if (ch === '/' && next === '*') {
5924
- inBlockComment = true;
5925
- continue;
5926
- }
5927
- if (ch === '"' || ch === "'" || ch === '`') {
5928
- inString = ch;
5929
- continue;
5930
- }
5931
- if (ch === '{')
5932
- braces++;
5933
- else if (ch === '}')
5934
- braces--;
5935
- else if (ch === '[')
5936
- brackets++;
5937
- else if (ch === ']')
5938
- brackets--;
5939
- else if (ch === '(')
5940
- parens++;
5941
- else if (ch === ')')
5942
- parens--;
5943
- }
5944
- if (braces !== 0)
5945
- errors.push(`Mismatched braces (balance: ${braces > 0 ? '+' : ''}${braces})`);
5946
- if (brackets !== 0)
5947
- errors.push(`Mismatched brackets (balance: ${brackets > 0 ? '+' : ''}${brackets})`);
5948
- if (parens !== 0)
5949
- errors.push(`Mismatched parentheses (balance: ${parens > 0 ? '+' : ''}${parens})`);
5950
- // Detect obvious keyword typos
5951
- const lines = code.split('\n');
5952
- for (let li = 0; li < lines.length; li++) {
5953
- const line = lines[li];
5954
- if (/\bfuncion\b|\bfuction\b|\bfuntion\b/i.test(line))
5955
- errors.push(`Line ${li + 1}: Misspelled 'function'`);
5956
- if (/\bretrun\b/i.test(line))
5957
- errors.push(`Line ${li + 1}: Misspelled 'return'`);
5958
- if (/\bconts\b|\bcosnt\b/i.test(line))
5959
- errors.push(`Line ${li + 1}: Misspelled 'const'`);
5960
- }
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
+ });
5961
3621
  }
5962
- if (lang === 'python' || lang === 'py') {
5963
- const lines = code.split('\n');
5964
- for (let li = 0; li < lines.length; li++) {
5965
- const line = lines[li];
5966
- if (/^\s*(def|class|if|elif|else|for|while|try|except|with)\b/.test(line) && !line.trimEnd().endsWith(':') && !line.trimEnd().endsWith('\\')) {
5967
- errors.push(`Line ${li + 1}: Missing colon after '${line.trim().split(/\s/)[0]}'`);
5968
- }
3622
+ catch (error) {
3623
+ if (error instanceof OperatorClientError) {
3624
+ throw new CLIError(error.message, error.category, { cause: error });
5969
3625
  }
3626
+ throw error;
5970
3627
  }
5971
- return errors.join('; ');
5972
3628
  }
5973
3629
  /**
5974
- * Strip comment lines that the model added during a fix but were not
5975
- * present in the original code. Used for syntax-only fixes where the
5976
- * 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!
5977
3639
  */
5978
- stripInjectedComments(original, fixed, language) {
5979
- const lang = language.toLowerCase();
5980
- // Only handle JS/TS/Python single-line comment patterns for now
5981
- let wholeLineRe;
5982
- let inlineRe;
5983
- if (lang === 'python' || lang === 'py') {
5984
- wholeLineRe = /^\s*#\s/;
5985
- inlineRe = /\s+#\s.*$/;
5986
- }
5987
- else {
5988
- wholeLineRe = /^\s*\/\/\s/;
5989
- inlineRe = /\s+\/\/\s.*$/;
5990
- }
5991
- const origLines = original.split('\n');
5992
- const origSet = new Set(origLines.map(l => l.trim()));
5993
- const fixedLines = fixed.split('\n');
5994
- const result = [];
5995
- for (let idx = 0; idx < fixedLines.length; idx++) {
5996
- let line = fixedLines[idx];
5997
- // Remove whole-line comments not in original
5998
- if (wholeLineRe.test(line) && !origSet.has(line.trim())) {
5999
- continue;
6000
- }
6001
- // Strip inline trailing comments the model added.
6002
- // Only strip if the corresponding original line at the same position
6003
- // didn't have an inline comment at all.
6004
- if (inlineRe.test(line)) {
6005
- const origLine = idx < origLines.length ? origLines[idx] : '';
6006
- if (!inlineRe.test(origLine)) {
6007
- line = line.replace(inlineRe, '');
6008
- }
6009
- }
6010
- result.push(line);
6011
- }
6012
- 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');
6013
3648
  }
6014
- /**
6015
- * Ensure the fixed code hasn't lost closing delimiters relative to the
6016
- * original. Counts {, }, (, ), [, ] outside strings/comments and if
6017
- * the fix has fewer closers than the original, appends the missing ones.
6018
- */
6019
- repairBracketBalance(original, fixed) {
6020
- const count = (src) => {
6021
- let braces = 0, parens = 0, brackets = 0;
6022
- let inStr = null;
6023
- let inLine = false, inBlock = false;
6024
- for (let i = 0; i < src.length; i++) {
6025
- const ch = src[i], nx = src[i + 1] || '';
6026
- if (inLine) {
6027
- if (ch === '\n')
6028
- inLine = false;
6029
- continue;
6030
- }
6031
- if (inBlock) {
6032
- if (ch === '*' && nx === '/') {
6033
- inBlock = false;
6034
- i++;
6035
- }
6036
- continue;
6037
- }
6038
- if (inStr) {
6039
- if (ch === inStr && src[i - 1] !== '\\')
6040
- inStr = null;
6041
- continue;
6042
- }
6043
- if (ch === '/' && nx === '/') {
6044
- inLine = true;
6045
- continue;
6046
- }
6047
- if (ch === '/' && nx === '*') {
6048
- inBlock = true;
6049
- continue;
6050
- }
6051
- if (ch === '"' || ch === "'" || ch === '`') {
6052
- inStr = ch;
6053
- continue;
6054
- }
6055
- if (ch === '{')
6056
- braces++;
6057
- else if (ch === '}')
6058
- braces--;
6059
- else if (ch === '(')
6060
- parens++;
6061
- else if (ch === ')')
6062
- parens--;
6063
- else if (ch === '[')
6064
- brackets++;
6065
- else if (ch === ']')
6066
- brackets--;
6067
- }
6068
- return { braces, parens, brackets };
6069
- };
6070
- const orig = count(original);
6071
- const fix = count(fixed);
6072
- const append = [];
6073
- // If original was balanced (or close) but fix is missing closers,
6074
- // append them. Only act when the fix is *more unbalanced* than original.
6075
- if (fix.braces > orig.braces) {
6076
- for (let i = 0; i < fix.braces - orig.braces; i++)
6077
- append.push('}');
6078
- }
6079
- if (fix.parens > orig.parens) {
6080
- for (let i = 0; i < fix.parens - orig.parens; i++)
6081
- append.push(')');
6082
- }
6083
- if (fix.brackets > orig.brackets) {
6084
- for (let i = 0; i < fix.brackets - orig.brackets; i++)
6085
- append.push(']');
6086
- }
6087
- if (append.length > 0) {
6088
- return fixed.trimEnd() + '\n' + append.join('\n');
6089
- }
6090
- 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);
6091
3674
  }
6092
3675
  // Model resolution - maps Vigthoria model names to internal IDs
6093
3676
  // INTERNAL USE ONLY - users see only Vigthoria branding
6094
3677
  resolveModelId(shortName) {
6095
- const modelMap = {
6096
- // ═══════════════════════════════════════════════════════════════
6097
- // Vigthoria server infrastructure models
6098
- // ═══════════════════════════════════════════════════════════════
6099
- 'fast': 'Vigthoria-v4-Assistant-9B',
6100
- 'mini': 'vigthoria-mini-0.6b',
6101
- 'balanced': 'Vigthoria-v4-Assistant-9B',
6102
- 'balanced-4b': 'Vigthoria-v4-Assistant-9B',
6103
- 'assistant': 'Vigthoria-v4-Assistant-9B',
6104
- 'creative': 'Vigthoria-v4-Creative-27B',
6105
- 'architect': 'Vigthoria-v4-Creative-27B',
6106
- // Code Models - 35B is the default powerhouse
6107
- 'code': 'Vigthoria-v4-Code-27B',
6108
- 'code-30b': 'Vigthoria-v4-Code-27B',
6109
- 'code-35b': 'Vigthoria-v4-Code-27B',
6110
- 'code-8b': 'Vigthoria-v4-Assistant-9B',
6111
- 'code-9b': 'Vigthoria-v4-Assistant-9B',
6112
- 'pro': 'Vigthoria-v4-Code-27B',
6113
- 'agent': 'Vigthoria-v4-Code-27B',
6114
- 'vigthoria-code': 'Vigthoria-v4-Code-27B',
6115
- 'vigthoria-agent': 'Vigthoria-v4-Code-27B',
6116
- // ═══════════════════════════════════════════════════════════════
6117
- // VIGTHORIA CLOUD - current billing catalog aliases
6118
- // ═══════════════════════════════════════════════════════════════
6119
- 'cloud': 'vigthoria-cloud-balanced',
6120
- 'cloud-code': 'vigthoria-cloud-code',
6121
- 'cloud-balanced': 'vigthoria-cloud-balanced',
6122
- 'cloud-fast': 'vigthoria-cloud-fast',
6123
- 'cloud-power': 'vigthoria-cloud-power',
6124
- 'cloud-maximum': 'vigthoria-cloud-maximum',
6125
- 'cloud-reason': 'vigthoria-cloud-power',
6126
- 'ultra': 'vigthoria-cloud-maximum',
6127
- // Legacy aliases kept for backward compatibility
6128
- 'cloud-pro': 'vigthoria-cloud-power',
6129
- 'cloud-k2': 'vigthoria-cloud-power',
6130
- 'cloud-ultra': 'vigthoria-cloud-maximum',
6131
- };
6132
- // If already a full model ID, return as-is
6133
- const normalizedShortName = String(shortName || '').toLowerCase();
6134
- if (normalizedShortName.includes('vigthoria') || shortName.includes('/') || shortName.includes(':')) {
6135
- if (modelMap[normalizedShortName]) {
6136
- return modelMap[normalizedShortName];
6137
- }
6138
- return shortName;
6139
- }
6140
- return modelMap[normalizedShortName] || 'Vigthoria-v4-Code-27B';
3678
+ return this.modelGovernance.resolveModelId(shortName);
6141
3679
  }
6142
3680
  async getCoderHealth() {
6143
3681
  try {
@@ -6164,75 +3702,10 @@ document.addEventListener('DOMContentLoaded', () => {
6164
3702
  * so users see a clear pass/fail before "Planning..." hangs on slow routes.
6165
3703
  */
6166
3704
  async runChatModelPreflight(requestedModel = 'agent') {
6167
- const routes = [];
6168
- const probes = [
6169
- {
6170
- name: 'Vigthoria Coder API',
6171
- run: async () => {
6172
- const health = await this.getCoderHealth();
6173
- return { ok: health.ok, error: health.error };
6174
- },
6175
- },
6176
- {
6177
- name: 'Vigthoria Models API',
6178
- run: async () => {
6179
- const health = await this.getModelsHealth();
6180
- return { ok: health.ok, error: health.error };
6181
- },
6182
- },
6183
- ];
6184
- const selfHostedHealth = await this.getSelfHostedHealth();
6185
- if (selfHostedHealth) {
6186
- const sh = selfHostedHealth;
6187
- probes.unshift({
6188
- name: sh.name || 'Dedicated Models API',
6189
- run: async () => ({ ok: sh.ok, error: sh.error }),
6190
- });
6191
- }
6192
- if (this.isSelfHostedPreferredModel(this.resolveModelId(requestedModel), requestedModel)) {
6193
- // Agent/code models should also accept V3 health as a usable inference path.
6194
- probes.push({
6195
- name: 'V3 Agent API',
6196
- run: async () => {
6197
- const v3 = await this.runV3HealthCheck();
6198
- return { ok: v3.healthy, error: v3.error };
6199
- },
6200
- });
6201
- }
6202
- const results = await Promise.all(probes.map(async (probe) => {
6203
- try {
6204
- const result = await probe.run();
6205
- routes.push({ name: probe.name, ok: result.ok, error: result.error });
6206
- return result.ok;
6207
- }
6208
- catch (error) {
6209
- const message = error?.message || String(error);
6210
- routes.push({ name: probe.name, ok: false, error: message });
6211
- return false;
6212
- }
6213
- }));
6214
- const healthy = results.some(Boolean);
6215
- const inferenceRoutes = routes.filter((route) => route.name !== 'V3 Agent API');
6216
- const firstHealthy = inferenceRoutes.find((route) => route.ok) || routes.find((route) => route.ok);
6217
- return {
6218
- healthy,
6219
- endpoint: firstHealthy?.name || 'api.vigthoria.io',
6220
- error: healthy
6221
- ? undefined
6222
- : 'No Vigthoria model backend responded during preflight. Run `vigthoria login` and check your internet connection.',
6223
- routes,
6224
- };
3705
+ return this.capabilityHealthService.runModelPreflight(requestedModel);
6225
3706
  }
6226
3707
  mapPreflightEndpointToRoute(endpoint) {
6227
- const normalized = String(endpoint || '').toLowerCase();
6228
- if (normalized.includes('coder'))
6229
- return 'coder';
6230
- if (normalized.includes('models'))
6231
- return 'models';
6232
- if (normalized.includes('dedicated') || normalized.includes('self-hosted') || normalized.includes('selfhosted')) {
6233
- return 'selfhosted';
6234
- }
6235
- return null;
3708
+ return this.capabilityHealthService.mapPreflightEndpoint(endpoint);
6236
3709
  }
6237
3710
  getLastChatTransportErrors() {
6238
3711
  return this.lastChatTransportErrors;
@@ -6272,7 +3745,7 @@ document.addEventListener('DOMContentLoaded', () => {
6272
3745
  error: String(result.error || ''),
6273
3746
  });
6274
3747
  for (let attempt = 0; attempt < 3; attempt += 1) {
6275
- const response = await fetch(endpoint, {
3748
+ const response = await this.fetch(endpoint, {
6276
3749
  method: 'POST',
6277
3750
  headers: { ...headers, 'Content-Type': 'application/json' },
6278
3751
  body,
@@ -6404,7 +3877,7 @@ document.addEventListener('DOMContentLoaded', () => {
6404
3877
  try {
6405
3878
  const controller = new AbortController();
6406
3879
  const timer = setTimeout(() => controller.abort(), 12000);
6407
- const response = await fetch(endpoint, {
3880
+ const response = await this.fetch(endpoint, {
6408
3881
  method: 'GET',
6409
3882
  headers,
6410
3883
  signal: controller.signal,
@@ -6452,7 +3925,7 @@ document.addEventListener('DOMContentLoaded', () => {
6452
3925
  try {
6453
3926
  const controller = new AbortController();
6454
3927
  const timer = setTimeout(() => controller.abort(), 2000);
6455
- const probe = await fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
3928
+ const probe = await this.fetch(runUrl, { method: 'OPTIONS', headers, signal: controller.signal });
6456
3929
  clearTimeout(timer);
6457
3930
  if (probe.ok || probe.status === 204 || probe.status === 405) {
6458
3931
  return {
@@ -6518,6 +3991,7 @@ document.addEventListener('DOMContentLoaded', () => {
6518
3991
  ...(hyperLoopServiceKey ? { 'X-Service-Key': hyperLoopServiceKey } : {}),
6519
3992
  };
6520
3993
  const projectPath = this.resolveAgentTargetPath(context);
3994
+ const projectAlias = `vigthoria://workspace/${encodeURIComponent(path.basename(projectPath || 'workspace'))}`;
6521
3995
  try {
6522
3996
  const modulesResponse = await fetchWithServiceTimeout(modulesEndpoint, {
6523
3997
  method: 'GET',
@@ -6537,13 +4011,13 @@ document.addEventListener('DOMContentLoaded', () => {
6537
4011
  body: JSON.stringify({
6538
4012
  module: 'repo_context_compactor',
6539
4013
  payload: {
6540
- path: projectPath,
4014
+ path: projectAlias,
6541
4015
  request_focus: 'Capability truth status probe',
6542
4016
  max_files: 20,
6543
4017
  max_chars: 800,
6544
4018
  },
6545
4019
  context: {
6546
- project_path: projectPath,
4020
+ project_path: projectAlias,
6547
4021
  source: 'vigthoria-cli-capability-truth',
6548
4022
  },
6549
4023
  }),
@@ -6608,19 +4082,38 @@ document.addEventListener('DOMContentLoaded', () => {
6608
4082
  message: 'V3 service is reachable again — retrying the workflow once.',
6609
4083
  };
6610
4084
  }
6611
- async getDevtoolsBridgeStatus() {
4085
+ async probeDevtoolsBridgeStatus() {
6612
4086
  const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
6613
4087
  const port = Number.parseInt(process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016', 10);
6614
4088
  const endpoint = `ws://${host}:${port}/ws`;
6615
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
+ }
6616
4104
  try {
6617
4105
  const controller = new AbortController();
6618
4106
  const timer = setTimeout(() => controller.abort(), 1500);
6619
- const response = await fetch(healthEndpoint, {
6620
- signal: controller.signal,
6621
- headers: { Accept: 'application/json' },
6622
- });
6623
- 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
+ }
6624
4117
  if (response.ok) {
6625
4118
  return {
6626
4119
  name: 'DevTools Bridge',
@@ -6668,40 +4161,14 @@ document.addEventListener('DOMContentLoaded', () => {
6668
4161
  });
6669
4162
  });
6670
4163
  }
4164
+ async getDevtoolsBridgeStatus() {
4165
+ return this.probeDevtoolsBridgeStatus();
4166
+ }
6671
4167
  async getCapabilityTruthStatus(context = {}) {
6672
- // The public edge can legitimately take 5-10 seconds during cold routing.
6673
- // Keep a finite ceiling, but do not turn a healthy slow edge into the old
6674
- // hard 10-second preflight failure seen on external client machines.
6675
- const withTimeout = (p, name) => Promise.race([
6676
- p,
6677
- new Promise(resolve => setTimeout(() => resolve({ name, endpoint: '', ok: false, error: 'Service not reachable (15 s timeout)' }), 15000)),
6678
- ]);
6679
- const [v3Agent, hyperLoop, repoMemory, devtoolsBridge] = await Promise.all([
6680
- withTimeout(this.getV3AgentHealth(), 'V3 Agent'),
6681
- withTimeout(this.getHyperLoopHealth(), 'Hyper Loop'),
6682
- withTimeout(this.getRepoMemoryHealth(context), 'Repo Memory'),
6683
- withTimeout(this.getDevtoolsBridgeStatus(), 'DevTools Bridge'),
6684
- ]);
6685
- return {
6686
- overallOk: v3Agent.ok && (hyperLoop.ok || repoMemory.ok),
6687
- v3Agent,
6688
- hyperLoop,
6689
- repoMemory,
6690
- devtoolsBridge,
6691
- };
4168
+ return this.capabilityHealthService.getCapabilityTruth(context);
6692
4169
  }
6693
4170
  async getHealthStatus() {
6694
- const [coder, models, selfHosted] = await Promise.all([
6695
- this.getCoderHealth(),
6696
- this.getModelsHealth(),
6697
- this.getSelfHostedHealth(),
6698
- ]);
6699
- return {
6700
- overallOk: coder.ok && models.ok,
6701
- coder,
6702
- models,
6703
- selfHosted,
6704
- };
4171
+ return this.capabilityHealthService.getHealth();
6705
4172
  }
6706
4173
  // Health check
6707
4174
  async healthCheck() {