vigthoria-cli 1.12.3 → 1.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { BridgeCommand } from './commands/bridge.js';
31
31
  import { DeviceCommand } from './commands/device.js';
32
32
  import { WorkflowCommand } from './commands/workflow.js';
33
33
  import { PreviewCommand } from './commands/preview.js';
34
+ import { GameCommand } from './commands/game.js';
34
35
  import { LegionCommand } from './commands/legion.js';
35
36
  import { HistoryCommand } from './commands/history.js';
36
37
  import { ReplayCommand } from './commands/replay.js';
@@ -38,7 +39,7 @@ import { ForkCommand } from './commands/fork.js';
38
39
  import { CancelCommand } from './commands/cancel.js';
39
40
  import { BackgroundCommand } from './commands/background.js';
40
41
  import { SecurityCommand } from './commands/security.js';
41
- import { V4Command, defaultV4PythonBin, defaultV4AgentDir } from './commands/v4.js';
42
+ import { V4Command } from './commands/v4.js';
42
43
  import { runV4Menu } from './commands/v4-menu.js';
43
44
  import { Config } from './utils/config.js';
44
45
  import { Logger, CH } from './utils/logger.js';
@@ -49,6 +50,7 @@ import * as os from 'os';
49
50
  import { fileURLToPath } from 'url';
50
51
  import { createHash } from 'crypto';
51
52
  import axios from 'axios';
53
+ import { renderDynamicHelp, selectInteractiveCommand } from './utils/command-menu.js';
52
54
  // ESM shim — TypeScript emits ESM under "type": "module", so __dirname
53
55
  // is not available natively. Restore the CommonJS-style helper so the
54
56
  // pre-existing package-discovery logic continues to work unchanged.
@@ -58,6 +60,44 @@ import { APIClient, sanitizeUserFacingErrorText } from './utils/api.js';
58
60
  import { getCliStateFile, isOfflineMode, isSafeNpmPackageSpec, isUpdateCheckSuppressed, readCachedLatestVersion, readGatewayPreflightCache, writeCachedLatestVersion, writeGatewayPreflightCache, } from './utils/cli-state.js';
59
61
  import { applyLocalTestfarmDefaults, isLocalTestfarmMode } from './utils/localTestMode.js';
60
62
  applyLocalTestfarmDefaults();
63
+ // --- Terminal column-width safety clamp -----------------------------------
64
+ // Node's `readline` module (used for the interactive chat `> ` prompt and a
65
+ // few other prompts) redraws the current input line on every keystroke using
66
+ // cursor-position math derived from `process.stdout.columns`. When this CLI
67
+ // runs inside a freshly-spawned ConPTY child (e.g. the Vigthoria Workbench's
68
+ // embedded terminal pane on Windows), there is a brief window right after
69
+ // process start where the console width hasn't been reported yet (or is
70
+ // reported as an implausibly small value like 0/1) before the real terminal
71
+ // size propagates down to the child process. Because `readline` reads
72
+ // `columns` fresh on every redraw (not just once at startup), an early or
73
+ // transient bad reading makes it believe the terminal is only 1 column wide,
74
+ // so it moves the cursor to a new row (and re-indents) after every single
75
+ // typed character -- producing a "staircase" of one-character lines that
76
+ // looks exactly like what's reported. This has nothing to do with the host
77
+ // terminal's real (correct) width; it's purely readline believing a bad
78
+ // number. Clamp `columns`/`rows` to sane floors up front and keep them
79
+ // clamped on every real resize event so readline never sees an implausible
80
+ // value, regardless of what the host terminal actually reports.
81
+ const MIN_TTY_COLUMNS = 30;
82
+ const MIN_TTY_ROWS = 4;
83
+ function clampStreamSize(stream) {
84
+ if (!stream || !stream.isTTY)
85
+ return;
86
+ if (!Number.isFinite(stream.columns) || (stream.columns ?? 0) < MIN_TTY_COLUMNS) {
87
+ stream.columns = 80;
88
+ }
89
+ if (!Number.isFinite(stream.rows) || (stream.rows ?? 0) < MIN_TTY_ROWS) {
90
+ stream.rows = 24;
91
+ }
92
+ }
93
+ clampStreamSize(process.stdout);
94
+ clampStreamSize(process.stderr);
95
+ if (process.stdout.isTTY) {
96
+ process.stdout.on('resize', () => clampStreamSize(process.stdout));
97
+ }
98
+ if (process.stderr.isTTY) {
99
+ process.stderr.on('resize', () => clampStreamSize(process.stderr));
100
+ }
61
101
  function isApiError(error) {
62
102
  return Boolean(error &&
63
103
  typeof error === 'object' &&
@@ -200,10 +240,14 @@ function maxVersion(...versions) {
200
240
  async function fetchNpmLatestVersion() {
201
241
  const { execSync } = await import('child_process');
202
242
  try {
243
+ // Hard 5s timeout: a slow/blocked path to the public npm registry must
244
+ // never hang the caller (e.g. the Workbench's blocking `vigthoria update`
245
+ // invocation) indefinitely.
203
246
  const latest = execSync('npm view vigthoria-cli version', {
204
247
  encoding: 'utf8',
205
248
  stdio: ['pipe', 'pipe', 'pipe'],
206
249
  windowsHide: true,
250
+ timeout: 5000,
207
251
  }).trim();
208
252
  return latest || null;
209
253
  }
@@ -212,7 +256,7 @@ async function fetchNpmLatestVersion() {
212
256
  }
213
257
  }
214
258
  const VERSION = getVersion();
215
- const VIGTHORIA_DEFAULT_MANIFEST_URL = process.env.VIGTHORIA_UPDATE_MANIFEST_URL || "https://extension.vigthoria.io/downloads/manifest.json";
259
+ const VIGTHORIA_DEFAULT_MANIFEST_URL = process.env.VIGTHORIA_UPDATE_MANIFEST_URL || "https://coder.vigthoria.io/releases/manifest.json";
216
260
  function resolveManifestEntry(manifest, channel) {
217
261
  const normalized = String(channel || 'stable').trim() || 'stable';
218
262
  if (manifest.channels && manifest.channels[normalized]) {
@@ -321,6 +365,10 @@ async function installGlobalPackageWithNpm(packageSpec) {
321
365
  stdio: 'inherit',
322
366
  windowsHide: true,
323
367
  shell: attempt.shell === true,
368
+ // Hard cap so a stalled/hung npm install (registry outage, network
369
+ // issue, etc.) can never block the caller indefinitely - especially
370
+ // important since the Workbench invokes this synchronously.
371
+ timeout: 180000,
324
372
  });
325
373
  return;
326
374
  }
@@ -646,7 +694,7 @@ export async function main(args) {
646
694
  .option('--auto-approve', 'Auto-approve agent actions (dangerous!)', false)
647
695
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
648
696
  .action(async (options) => {
649
- const chat = new ChatCommand(config, logger);
697
+ const chat = new ChatCommand(config, logger, program);
650
698
  await chat.run({
651
699
  model: options.model,
652
700
  project: options.project,
@@ -675,7 +723,7 @@ export async function main(args) {
675
723
  .option('--auto-approve', 'Auto-approve agent actions (dangerous!)', false)
676
724
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
677
725
  .action(async (options) => {
678
- const chat = new ChatCommand(config, logger);
726
+ const chat = new ChatCommand(config, logger, program);
679
727
  await chat.run({
680
728
  model: options.model,
681
729
  project: options.project,
@@ -707,7 +755,7 @@ export async function main(args) {
707
755
  .option('--auto-approve', 'Auto-approve all actions (dangerous!)', false)
708
756
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
709
757
  .action(async (options) => {
710
- const chat = new ChatCommand(config, logger);
758
+ const chat = new ChatCommand(config, logger, program);
711
759
  await chat.run({
712
760
  model: options.model,
713
761
  project: options.project,
@@ -737,7 +785,7 @@ export async function main(args) {
737
785
  .option('--json', 'Emit machine-readable JSON output for direct prompt runs', false)
738
786
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
739
787
  .action(async (options) => {
740
- const chat = new ChatCommand(config, logger);
788
+ const chat = new ChatCommand(config, logger, program);
741
789
  await chat.run({
742
790
  model: options.model,
743
791
  project: options.project,
@@ -752,37 +800,7 @@ export async function main(args) {
752
800
  bridge: options.bridge,
753
801
  });
754
802
  });
755
- program
756
- .command('v4')
757
- .alias('v4-agent')
758
- .description('Launch Vigthoria V4 Operating Agent (DeerFlow 2.0 harness)')
759
- .option('--provider <provider>', 'Provider: cloud | v3_local | byok')
760
- .option('--api-key <key>', 'BYOK API key for non-interactive mode')
761
- .option('--base-url <url>', 'Provider base URL override')
762
- .option('--model <name>', 'Provider model override')
763
- .option('--transport <mode>', 'Bridge transport: auto | ws | sse', 'auto')
764
- .option('--harness-stream-url <url>', 'Override harness stream URL')
765
- .option('--harness-control-url <url>', 'Override harness control URL')
766
- .option('--session-id <id>', 'Explicit V4 session id')
767
- .option('--non-interactive', 'Disable interactive menu and use CLI options', false)
768
- .option('--python <bin>', 'Python binary to use', defaultV4PythonBin())
769
- .option('--v4-path <path>', 'V4 agent root path (or set VIGTHORIA_V4_AGENT_DIR)', defaultV4AgentDir())
770
- .action(async (options) => {
771
- const v4 = new V4Command(config, logger);
772
- await v4.run({
773
- provider: options.provider,
774
- apiKey: options.apiKey,
775
- baseUrl: options.baseUrl,
776
- model: options.model,
777
- transport: options.transport,
778
- harnessStreamUrl: options.harnessStreamUrl,
779
- harnessControlUrl: options.harnessControlUrl,
780
- sessionId: options.sessionId,
781
- nonInteractive: options.nonInteractive,
782
- python: options.python,
783
- v4Path: options.v4Path,
784
- });
785
- });
803
+ new V4Command(config, logger).register(program);
786
804
  program
787
805
  .command('v4-menu')
788
806
  .description('Internal: interactive V3/V4 agent + provider picker used by the V4 Operating Agent launcher')
@@ -1143,20 +1161,21 @@ export async function main(args) {
1143
1161
  await music.status(taskId, options);
1144
1162
  });
1145
1163
  // ==================== REPO COMMANDS ====================
1146
- // Repo command - Push/Pull projects to/from Vigthoria Repository
1164
+ // Repo command - Push/Pull projects to/from Vigthoria Community Repository
1147
1165
  const repoCommand = program
1148
1166
  .command('repo')
1149
1167
  .alias('repository')
1150
- .description('Push and pull projects to/from your Vigthoria Repository');
1168
+ .description('Push and pull projects to/from your Vigthoria Community Repository');
1151
1169
  repoCommand
1152
1170
  .command('push [path]')
1153
1171
  .alias('upload')
1154
- .description('Push current or specified project to Vigthoria Repo')
1172
+ .description('Push current or specified project to Vigthoria Community Repository')
1155
1173
  .option('-n, --name <projectName>', 'Project name override for the remote repo')
1156
1174
  .option('-v, --visibility <type>', 'Set visibility (private, restricted, public)', 'private')
1157
1175
  .option('-d, --description <text>', 'Project description')
1158
1176
  .option('-f, --force', 'Overwrite existing project', false)
1159
1177
  .option('-y, --yes', 'Run non-interactively with provided/default values', false)
1178
+ .option('--retry <reviewId>', 'Link this corrected push to a repository security review')
1160
1179
  .option('--open-in <engine>', 'After push, open project in engine: shop, visual, game')
1161
1180
  .option('--browser', 'Open result URL in default browser', false)
1162
1181
  .action(async (pathArg, options) => {
@@ -1167,7 +1186,8 @@ export async function main(args) {
1167
1186
  visibility: options.visibility,
1168
1187
  description: options.description,
1169
1188
  force: options.force,
1170
- yes: options.yes
1189
+ yes: options.yes,
1190
+ retry: options.retry
1171
1191
  });
1172
1192
  if (options.openIn) {
1173
1193
  const engine = options.openIn;
@@ -1179,10 +1199,18 @@ export async function main(args) {
1179
1199
  }
1180
1200
  }
1181
1201
  });
1202
+ repoCommand
1203
+ .command('review [reviewId]')
1204
+ .description('Show repository security review status, findings, and retry guidance')
1205
+ .option('--json', 'Emit machine-readable JSON output', false)
1206
+ .action(async (reviewId, options) => {
1207
+ const repo = new RepoCommand(config, logger);
1208
+ await repo.review(reviewId, { json: options.json });
1209
+ });
1182
1210
  repoCommand
1183
1211
  .command('pull <name>')
1184
1212
  .alias('download')
1185
- .description('Pull a project from your Vigthoria Repo')
1213
+ .description('Pull a project from your Vigthoria Community Repository')
1186
1214
  .option('-o, --output <path>', 'Output directory path')
1187
1215
  .option('-f, --force', 'Overwrite existing directory', false)
1188
1216
  .action(async (name, options) => {
@@ -1195,7 +1223,7 @@ export async function main(args) {
1195
1223
  repoCommand
1196
1224
  .command('list')
1197
1225
  .alias('ls')
1198
- .description('List all your projects in Vigthoria Repo')
1226
+ .description('List all your projects in Vigthoria Community Repository')
1199
1227
  .option('-v, --visibility <type>', 'Filter by visibility (private, restricted, public)')
1200
1228
  .action(async (options) => {
1201
1229
  const repo = new RepoCommand(config, logger);
@@ -1219,14 +1247,14 @@ export async function main(args) {
1219
1247
  repoCommand
1220
1248
  .command('delete <name>')
1221
1249
  .alias('rm')
1222
- .description('Remove a project from your Vigthoria Repo')
1250
+ .description('Remove a project from your Vigthoria Community Repository')
1223
1251
  .action(async (name) => {
1224
1252
  const repo = new RepoCommand(config, logger);
1225
1253
  await repo.delete(name);
1226
1254
  });
1227
1255
  repoCommand
1228
1256
  .command('clone <url>')
1229
- .description('Clone a public project from Vigthoria Repo')
1257
+ .description('Clone a public project from Vigthoria Community')
1230
1258
  .option('-o, --output <path>', 'Output directory path')
1231
1259
  .option('-f, --force', 'Overwrite existing directory', false)
1232
1260
  .action(async (url, options) => {
@@ -1238,7 +1266,7 @@ export async function main(args) {
1238
1266
  });
1239
1267
  repoCommand
1240
1268
  .command('open-in <engine> [projectName]')
1241
- .description('Open a Vigthoria Repo project in the Shop Engine, Visual Editor, or Gaming Engine')
1269
+ .description('Open a Vigthoria Community Repository project in the Shop Engine, Visual Editor, or Gaming Engine')
1242
1270
  .option('--browser', 'Open result URL in default browser', false)
1243
1271
  .option('--shop-id <id>', 'Target shop ID (for shop engine)', 'default')
1244
1272
  .addHelpText('after', `
@@ -1416,6 +1444,12 @@ Examples:
1416
1444
  screenshot: options.screenshot,
1417
1445
  });
1418
1446
  });
1447
+ // ==================== GAME COMMAND ====================
1448
+ const gameCommand = program.command('game').description('Create, run, and validate Babylon/VGE game projects');
1449
+ gameCommand.command('init [name]').description('Create a production-ready client-rendered Babylon/VGE game').option('--engine <engine>', 'Game engine', 'babylon').option('--no-install', 'Create files without installing dependencies').option('--package-manager <manager>', 'npm, pnpm, yarn, or bun').action(async (name, options) => { await new GameCommand(logger).init(name, options); });
1450
+ gameCommand.command('run').description('Run the local game development server').option('-p, --project <path>', 'Game project directory', process.cwd()).option('--port <number>', 'Development server port', parseInt).option('--no-open', 'Do not open a browser').option('--package-manager <manager>', 'npm, pnpm, yarn, or bun').action(async (options) => { await new GameCommand(logger).run(options); });
1451
+ gameCommand.command('validate').description('Build and validate a Babylon/VGE game project').option('-p, --project <path>', 'Game project directory', process.cwd()).option('--json', 'Print machine-readable validation results').option('--package-manager <manager>', 'npm, pnpm, yarn, or bun').action(async (options) => { await new GameCommand(logger).validate(options); });
1452
+ gameCommand.action(() => gameCommand.outputHelp());
1419
1453
  // ==================== LEGION COMMAND ====================
1420
1454
  program
1421
1455
  .command('legion [request]')
@@ -1676,10 +1710,23 @@ Examples:
1676
1710
  console.log(chalk.gray('Continuing with npm registry check...'));
1677
1711
  }
1678
1712
  }
1679
- console.log(chalk.cyan('Checking npm registry...'));
1680
- const npmVersion = await fetchNpmLatestVersion();
1681
- if (!npmVersion) {
1682
- console.log(chalk.yellow('Could not read latest version from npm registry.'));
1713
+ // The release manifest (server-curated, sha256-verified) is the
1714
+ // authoritative source of truth for this private CLI. Only fall back
1715
+ // to probing the public npm registry when the manifest itself has no
1716
+ // usable entry - never let an npm version number override a valid,
1717
+ // verified manifest entry (npm install -g against the public registry
1718
+ // is slower, unverified, and has previously caused the Workbench's
1719
+ // update button to hang for minutes).
1720
+ let npmVersion = null;
1721
+ if (manifestEntry?.version && manifestEntry?.url) {
1722
+ console.log(chalk.gray('Release manifest has a valid entry; skipping public npm registry lookup.'));
1723
+ }
1724
+ else {
1725
+ console.log(chalk.cyan('Checking npm registry...'));
1726
+ npmVersion = await fetchNpmLatestVersion();
1727
+ if (!npmVersion) {
1728
+ console.log(chalk.yellow('Could not read latest version from npm registry.'));
1729
+ }
1683
1730
  }
1684
1731
  const effectiveLatest = maxVersion(manifestEntry?.version, npmVersion);
1685
1732
  if (!effectiveLatest) {
@@ -1687,11 +1734,6 @@ Examples:
1687
1734
  process.exitCode = 1;
1688
1735
  return;
1689
1736
  }
1690
- if (manifestEntry?.version
1691
- && npmVersion
1692
- && compareVersions(npmVersion, manifestEntry.version) > 0) {
1693
- console.log(chalk.yellow(`Release manifest (${manifestEntry.version}) is behind npm (${npmVersion}); npm will be used when needed.`));
1694
- }
1695
1737
  if (!allowDowngrade && compareVersions(effectiveLatest, currentVersion) <= 0) {
1696
1738
  console.log(chalk.green(`You are running the latest version (${currentVersion})`));
1697
1739
  return;
@@ -1807,6 +1849,21 @@ Examples:
1807
1849
  const configCmd = new ConfigCommand(config, logger);
1808
1850
  await configCmd.init();
1809
1851
  });
1852
+ program
1853
+ .command('menu')
1854
+ .description('Open the interactive command-family menu')
1855
+ .action(async () => {
1856
+ const selectedArgs = await selectInteractiveCommand(program);
1857
+ if (selectedArgs) {
1858
+ await program.parseAsync([args[0], args[1], ...selectedArgs]);
1859
+ }
1860
+ });
1861
+ program
1862
+ .command('commands')
1863
+ .description('List every CLI command grouped by command family')
1864
+ .action(() => {
1865
+ console.log(renderDynamicHelp(program));
1866
+ });
1810
1867
  const codingCommandDefinitions = [
1811
1868
  { name: 'edit', description: 'Edit code by describing the desired change', instruction: 'Edit the project according to this request' },
1812
1869
  { name: 'generate', description: 'Generate code from a prompt', instruction: 'Generate code for this request' },
@@ -1823,7 +1880,7 @@ Examples:
1823
1880
  .option('-p, --project <path>', 'Project directory', process.cwd())
1824
1881
  .action(async (requestParts = [], options) => {
1825
1882
  const requestText = requestParts.join(' ').trim();
1826
- const chat = new ChatCommand(config, logger);
1883
+ const chat = new ChatCommand(config, logger, program);
1827
1884
  await chat.run({
1828
1885
  model: options.model || 'code',
1829
1886
  project: options.project || process.cwd(),
@@ -1834,8 +1891,10 @@ Examples:
1834
1891
  try {
1835
1892
  // Default to chat if no command
1836
1893
  if (args.length === 2) {
1837
- const chat = new ChatCommand(config, logger);
1838
- await chat.run({ model: 'code', project: process.cwd() });
1894
+ const selectedArgs = await selectInteractiveCommand(program);
1895
+ if (selectedArgs) {
1896
+ await program.parseAsync([args[0], args[1], ...selectedArgs]);
1897
+ }
1839
1898
  process.exitCode = 0;
1840
1899
  return;
1841
1900
  }
@@ -277,6 +277,7 @@ export declare class APIClient {
277
277
  private listFrontendWorkspaceFiles;
278
278
  private chooseFrontendPreviewEntry;
279
279
  private extractLinkedFrontendAssets;
280
+ private buildFrontendForPreview;
280
281
  private gatherFrontendPreviewArtifacts;
281
282
  private captureFrontendPreviewScreenshot;
282
283
  private evaluateFrontendVisualProof;
package/dist/utils/api.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Connects to coder.vigthoria.io API endpoints
4
4
  */
5
5
  import axios from 'axios';
6
+ import { execFile } from 'child_process';
6
7
  import { randomUUID } from 'crypto';
7
8
  import fs from 'fs';
8
9
  import https from 'https';
@@ -743,8 +744,8 @@ export class APIClient {
743
744
  const urls = [
744
745
  process.env.VIGTHORIA_TEMPLATE_SERVICE_URL,
745
746
  process.env.TEMPLATE_SERVICE_URL,
746
- 'http://127.0.0.1:4011',
747
747
  `${configuredApiUrl}/api/template-service`,
748
+ 'http://127.0.0.1:4011',
748
749
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
749
750
  return [...new Set(urls)];
750
751
  }
@@ -842,7 +843,15 @@ export class APIClient {
842
843
  const baseName = path.posix.basename(relativePath).toLowerCase();
843
844
  const depth = relativePath.split('/').length - 1;
844
845
  let score = 0;
845
- if (relativePath.toLowerCase() === 'index.html') {
846
+ const normalizedPath = relativePath.toLowerCase();
847
+ const topLevelDir = normalizedPath.split('/')[0];
848
+ if (['dist', 'build', 'out'].includes(topLevelDir)) {
849
+ score += 320;
850
+ }
851
+ else if (topLevelDir === 'public') {
852
+ score += 180;
853
+ }
854
+ if (normalizedPath === 'index.html') {
846
855
  score += 200;
847
856
  }
848
857
  if (baseName === 'index.html') {
@@ -896,6 +905,34 @@ export class APIClient {
896
905
  js: Array.from(js),
897
906
  };
898
907
  }
908
+ async buildFrontendForPreview(rootPath) {
909
+ const packagePath = path.join(rootPath, 'package.json');
910
+ if (!fs.existsSync(packagePath)) {
911
+ return { attempted: false, succeeded: false };
912
+ }
913
+ try {
914
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
915
+ if (!packageJson.scripts?.build) {
916
+ return { attempted: false, succeeded: false };
917
+ }
918
+ }
919
+ catch (error) {
920
+ return { attempted: false, succeeded: false, error: `Invalid package.json: ${error?.message || String(error)}` };
921
+ }
922
+ const npmCommand = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
923
+ const npmArgs = process.platform === 'win32'
924
+ ? ['/d', '/s', '/c', 'npm run build']
925
+ : ['run', 'build'];
926
+ return await new Promise((resolve) => {
927
+ execFile(npmCommand, npmArgs, { cwd: rootPath, timeout: 150_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 }, (error) => {
928
+ if (error) {
929
+ resolve({ attempted: true, succeeded: false, error: sanitizeUserFacingErrorText(error.message).slice(0, 300) });
930
+ return;
931
+ }
932
+ resolve({ attempted: true, succeeded: true });
933
+ });
934
+ });
935
+ }
899
936
  gatherFrontendPreviewArtifacts(message = '', context = {}) {
900
937
  const rootPath = this.resolveAgentTargetPath(context);
901
938
  if (!rootPath || !fs.existsSync(rootPath)) {
@@ -1051,12 +1088,22 @@ export class APIClient {
1051
1088
  return { required: false, passed: true };
1052
1089
  }
1053
1090
  const rootPath = this.resolveAgentTargetPath(context);
1054
- const artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1091
+ let artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1092
+ let buildResult = null;
1093
+ if (rootPath && artifacts.error?.includes('at least one HTML entry file')) {
1094
+ buildResult = await this.buildFrontendForPreview(rootPath);
1095
+ if (buildResult.attempted) {
1096
+ artifacts = this.gatherFrontendPreviewArtifacts(message, context);
1097
+ }
1098
+ }
1055
1099
  if (artifacts.error || !artifacts.htmlPath || typeof artifacts.html !== 'string') {
1100
+ const buildDetail = buildResult?.attempted
1101
+ ? ` Build retry ${buildResult.succeeded ? 'completed but produced no HTML entry' : `failed: ${buildResult.error || 'unknown build error'}`}.`
1102
+ : '';
1056
1103
  return {
1057
1104
  required: true,
1058
1105
  passed: false,
1059
- error: artifacts.error || 'Frontend preview gate could not collect frontend artifacts.',
1106
+ error: `${artifacts.error || 'Frontend preview gate could not collect frontend artifacts.'}${buildDetail}`,
1060
1107
  };
1061
1108
  }
1062
1109
  // Cap artifact sizes to prevent 413 Payload Too Large
@@ -1083,11 +1130,19 @@ export class APIClient {
1083
1130
  error: 'Preview proof skipped: payload exceeds size limit.',
1084
1131
  };
1085
1132
  }
1133
+ const internalServiceKey = process.env.VIGTHORIA_TEMPLATE_SERVICE_KEY
1134
+ || process.env.V3_SERVICE_KEY;
1135
+ const authToken = this.getAccessToken();
1086
1136
  const response = await fetch(`${baseUrl}/preview-proof`, {
1087
1137
  method: 'POST',
1088
1138
  headers: {
1089
1139
  'Content-Type': 'application/json',
1090
1140
  Accept: 'application/json',
1141
+ ...(authToken ? {
1142
+ Authorization: `Bearer ${authToken}`,
1143
+ Cookie: `vigthoria-auth-token=${authToken}`,
1144
+ } : {}),
1145
+ ...(internalServiceKey ? { 'x-vigthoria-service-key': internalServiceKey } : {}),
1091
1146
  },
1092
1147
  body: proofPayload,
1093
1148
  });
@@ -1772,10 +1827,14 @@ export class APIClient {
1772
1827
  return finish();
1773
1828
  }
1774
1829
  /** Normalize common Unix-only commands before Windows cmd/powershell execution. */
1775
- normalizeWindowsRunCommand(command) {
1830
+ normalizeWindowsRunCommand(command, serverRoot = '') {
1776
1831
  let cmd = String(command || '').trim();
1777
1832
  if (!cmd)
1778
1833
  return cmd;
1834
+ if (serverRoot) {
1835
+ const escapedRoot = serverRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1836
+ cmd = cmd.replace(new RegExp(`^\\s*cd\\s+(?:[\"']?${escapedRoot}[\"']?)\\s*(?:&&|;)\\s*`, 'i'), '');
1837
+ }
1779
1838
  if (/^\/[^\s/]/.test(cmd) && !/^\/[a-z]:/i.test(cmd)) {
1780
1839
  throw new Error('Command starts with "/" which Windows interprets as an invalid switch. Use workspace-relative paths without a leading slash.');
1781
1840
  }
@@ -3082,38 +3141,35 @@ menu {
3082
3141
  return { success: false, output: '', error: 'run_command requires command.' };
3083
3142
  try {
3084
3143
  if (process.platform === 'win32') {
3085
- command = this.normalizeWindowsRunCommand(command);
3144
+ command = this.normalizeWindowsRunCommand(command, serverRoot);
3086
3145
  }
3087
3146
  }
3088
3147
  catch (error) {
3089
3148
  return { success: false, output: '', error: error.message };
3090
3149
  }
3091
3150
  const cwd = this.resolveRunCommandCwd(rootPath, args, serverRoot);
3092
- const usePowerShell = process.platform === 'win32'
3093
- && /^(npm|npx|node|yarn|pnpm|git|python|pip|powershell)\b/i.test(command);
3094
- const { exec } = await import('child_process');
3151
+ const { exec, execFile } = await import('child_process');
3095
3152
  return await new Promise((resolve) => {
3096
- const execOptions = {
3097
- cwd,
3098
- timeout: Number(args.timeout || 30000),
3099
- maxBuffer: 1024 * 1024,
3100
- env: process.env,
3101
- };
3102
- if (usePowerShell) {
3103
- execOptions.shell = 'powershell.exe';
3104
- execOptions.windowsHide = true;
3105
- command = `-NoProfile -ExecutionPolicy Bypass -Command ${JSON.stringify(command)}`;
3106
- }
3107
- else if (process.platform === 'win32') {
3108
- execOptions.shell = 'cmd.exe';
3109
- }
3110
- exec(command, execOptions, (error, stdout, stderr) => {
3153
+ const timeout = Number(args.timeout || 30000);
3154
+ const finish = (error, stdout, stderr) => {
3155
+ const stdoutText = String(stdout || '');
3156
+ const stderrText = String(stderr || '');
3157
+ const combined = [stdoutText, stderrText].filter(Boolean).join('\n').slice(0, 12000);
3111
3158
  resolve({
3112
3159
  success: !error,
3113
- output: String(stdout || stderr || '').slice(0, 12000),
3114
- error: error ? String(stderr || error.message) : '',
3160
+ output: combined,
3161
+ error: error ? String(error.message || error) : '',
3115
3162
  });
3116
- });
3163
+ };
3164
+ if (process.platform === 'win32') {
3165
+ const commandProcessor = process.env.ComSpec
3166
+ || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
3167
+ execFile(commandProcessor, ['/d', '/s', '/c', command], {
3168
+ cwd, timeout, maxBuffer: 1024 * 1024, env: process.env, windowsHide: true,
3169
+ }, finish);
3170
+ return;
3171
+ }
3172
+ exec(command, { cwd, timeout, maxBuffer: 1024 * 1024, env: process.env }, finish);
3117
3173
  });
3118
3174
  }
3119
3175
  return { success: false, output: '', error: `Unsupported client tool: ${name}` };
@@ -2,6 +2,22 @@
2
2
  * Brain Hub client — sync workspace index + fetch account brain context
3
3
  * via coder.vigthoria.io (JWT-authenticated).
4
4
  */
5
+ // Brain Hub calls are an optional personalization enhancement to an agent
6
+ // turn, not on the critical path. Native `fetch()` has NO default timeout,
7
+ // so a slow/unresponsive coder.vigthoria.io backend previously caused the
8
+ // ENTIRE agent turn to hang indefinitely with zero console output, well
9
+ // before any "ROUTING DECISION" text is printed (2026-07-10 root-cause).
10
+ const BRAIN_HUB_TIMEOUT_MS = 3500;
11
+ async function fetchWithTimeout(url, init = {}, timeoutMs = BRAIN_HUB_TIMEOUT_MS) {
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14
+ try {
15
+ return await fetch(url, { ...init, signal: controller.signal });
16
+ }
17
+ finally {
18
+ clearTimeout(timer);
19
+ }
20
+ }
5
21
  export class BrainHubClient {
6
22
  apiBase;
7
23
  getAuthToken;
@@ -22,27 +38,39 @@ export class BrainHubClient {
22
38
  if (!headers.Authorization) {
23
39
  return { ok: false, skipped: true, reason: 'not_authenticated' };
24
40
  }
25
- const resp = await fetch(`${this.apiBase}/api/brain/sync-index`, {
26
- method: 'POST',
27
- headers,
28
- body: JSON.stringify(payload),
29
- });
30
- const data = await resp.json().catch(() => ({}));
31
- if (!resp.ok) {
32
- return { ok: false, error: data.error || `HTTP ${resp.status}` };
41
+ try {
42
+ const resp = await fetchWithTimeout(`${this.apiBase}/api/brain/sync-index`, {
43
+ method: 'POST',
44
+ headers,
45
+ body: JSON.stringify(payload),
46
+ });
47
+ const data = await resp.json().catch(() => ({}));
48
+ if (!resp.ok) {
49
+ return { ok: false, error: data.error || `HTTP ${resp.status}` };
50
+ }
51
+ return data;
52
+ }
53
+ catch (error) {
54
+ const timedOut = error instanceof Error && error.name === 'AbortError';
55
+ return { ok: false, error: timedOut ? `timeout after ${BRAIN_HUB_TIMEOUT_MS}ms` : String(error) };
33
56
  }
34
- return data;
35
57
  }
36
58
  async fetchAccountContext(limit = 25) {
37
59
  const headers = await this.headers();
38
60
  if (!headers.Authorization) {
39
61
  return { ok: false, formattedText: '', memories: [] };
40
62
  }
41
- const resp = await fetch(`${this.apiBase}/api/brain/context?limit=${limit}`, { headers });
42
- const data = await resp.json().catch(() => ({}));
43
- if (!resp.ok) {
44
- return { ok: false, formattedText: '', error: data.error || `HTTP ${resp.status}` };
63
+ try {
64
+ const resp = await fetchWithTimeout(`${this.apiBase}/api/brain/context?limit=${limit}`, { headers });
65
+ const data = await resp.json().catch(() => ({}));
66
+ if (!resp.ok) {
67
+ return { ok: false, formattedText: '', error: data.error || `HTTP ${resp.status}` };
68
+ }
69
+ return data;
70
+ }
71
+ catch (error) {
72
+ const timedOut = error instanceof Error && error.name === 'AbortError';
73
+ return { ok: false, formattedText: '', error: timedOut ? `timeout after ${BRAIN_HUB_TIMEOUT_MS}ms` : String(error) };
45
74
  }
46
- return data;
47
75
  }
48
76
  }