vigthoria-cli 1.12.2 → 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
  }
@@ -585,8 +633,13 @@ export async function main(args) {
585
633
  !jsonOutputRequested &&
586
634
  !directPromptRequested &&
587
635
  !helpOrVersionRequested) {
636
+ // process.stdout.columns is not always a reliable indicator of how
637
+ // many columns actually render visually (seen mismatching badly in some
638
+ // embedded/split-pane terminal hosts on Windows) -- cap the box at a
639
+ // conservative width so it never overflows even when the reported
640
+ // column count is larger than the real usable width.
588
641
  const terminalWidth = Math.max(36, Number(process.stdout.columns || 80));
589
- const boxWidth = Math.max(34, Math.min(61, terminalWidth - 2));
642
+ const boxWidth = Math.max(34, Math.min(44, terminalWidth - 8));
590
643
  const innerWidth = boxWidth;
591
644
  const fit = (text) => {
592
645
  const clean = String(text || '').replace(/\s+/g, ' ').trim();
@@ -641,7 +694,7 @@ export async function main(args) {
641
694
  .option('--auto-approve', 'Auto-approve agent actions (dangerous!)', false)
642
695
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
643
696
  .action(async (options) => {
644
- const chat = new ChatCommand(config, logger);
697
+ const chat = new ChatCommand(config, logger, program);
645
698
  await chat.run({
646
699
  model: options.model,
647
700
  project: options.project,
@@ -670,7 +723,7 @@ export async function main(args) {
670
723
  .option('--auto-approve', 'Auto-approve agent actions (dangerous!)', false)
671
724
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
672
725
  .action(async (options) => {
673
- const chat = new ChatCommand(config, logger);
726
+ const chat = new ChatCommand(config, logger, program);
674
727
  await chat.run({
675
728
  model: options.model,
676
729
  project: options.project,
@@ -702,7 +755,7 @@ export async function main(args) {
702
755
  .option('--auto-approve', 'Auto-approve all actions (dangerous!)', false)
703
756
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
704
757
  .action(async (options) => {
705
- const chat = new ChatCommand(config, logger);
758
+ const chat = new ChatCommand(config, logger, program);
706
759
  await chat.run({
707
760
  model: options.model,
708
761
  project: options.project,
@@ -732,7 +785,7 @@ export async function main(args) {
732
785
  .option('--json', 'Emit machine-readable JSON output for direct prompt runs', false)
733
786
  .option('--bridge <url>', 'Connect to Vigthoria Commando Bridge for remote admin observability')
734
787
  .action(async (options) => {
735
- const chat = new ChatCommand(config, logger);
788
+ const chat = new ChatCommand(config, logger, program);
736
789
  await chat.run({
737
790
  model: options.model,
738
791
  project: options.project,
@@ -747,37 +800,7 @@ export async function main(args) {
747
800
  bridge: options.bridge,
748
801
  });
749
802
  });
750
- program
751
- .command('v4')
752
- .alias('v4-agent')
753
- .description('Launch Vigthoria V4 Operating Agent (DeerFlow 2.0 harness)')
754
- .option('--provider <provider>', 'Provider: cloud | v3_local | byok')
755
- .option('--api-key <key>', 'BYOK API key for non-interactive mode')
756
- .option('--base-url <url>', 'Provider base URL override')
757
- .option('--model <name>', 'Provider model override')
758
- .option('--transport <mode>', 'Bridge transport: auto | ws | sse', 'auto')
759
- .option('--harness-stream-url <url>', 'Override harness stream URL')
760
- .option('--harness-control-url <url>', 'Override harness control URL')
761
- .option('--session-id <id>', 'Explicit V4 session id')
762
- .option('--non-interactive', 'Disable interactive menu and use CLI options', false)
763
- .option('--python <bin>', 'Python binary to use', defaultV4PythonBin())
764
- .option('--v4-path <path>', 'V4 agent root path (or set VIGTHORIA_V4_AGENT_DIR)', defaultV4AgentDir())
765
- .action(async (options) => {
766
- const v4 = new V4Command(config, logger);
767
- await v4.run({
768
- provider: options.provider,
769
- apiKey: options.apiKey,
770
- baseUrl: options.baseUrl,
771
- model: options.model,
772
- transport: options.transport,
773
- harnessStreamUrl: options.harnessStreamUrl,
774
- harnessControlUrl: options.harnessControlUrl,
775
- sessionId: options.sessionId,
776
- nonInteractive: options.nonInteractive,
777
- python: options.python,
778
- v4Path: options.v4Path,
779
- });
780
- });
803
+ new V4Command(config, logger).register(program);
781
804
  program
782
805
  .command('v4-menu')
783
806
  .description('Internal: interactive V3/V4 agent + provider picker used by the V4 Operating Agent launcher')
@@ -1138,20 +1161,21 @@ export async function main(args) {
1138
1161
  await music.status(taskId, options);
1139
1162
  });
1140
1163
  // ==================== REPO COMMANDS ====================
1141
- // Repo command - Push/Pull projects to/from Vigthoria Repository
1164
+ // Repo command - Push/Pull projects to/from Vigthoria Community Repository
1142
1165
  const repoCommand = program
1143
1166
  .command('repo')
1144
1167
  .alias('repository')
1145
- .description('Push and pull projects to/from your Vigthoria Repository');
1168
+ .description('Push and pull projects to/from your Vigthoria Community Repository');
1146
1169
  repoCommand
1147
1170
  .command('push [path]')
1148
1171
  .alias('upload')
1149
- .description('Push current or specified project to Vigthoria Repo')
1172
+ .description('Push current or specified project to Vigthoria Community Repository')
1150
1173
  .option('-n, --name <projectName>', 'Project name override for the remote repo')
1151
1174
  .option('-v, --visibility <type>', 'Set visibility (private, restricted, public)', 'private')
1152
1175
  .option('-d, --description <text>', 'Project description')
1153
1176
  .option('-f, --force', 'Overwrite existing project', false)
1154
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')
1155
1179
  .option('--open-in <engine>', 'After push, open project in engine: shop, visual, game')
1156
1180
  .option('--browser', 'Open result URL in default browser', false)
1157
1181
  .action(async (pathArg, options) => {
@@ -1162,7 +1186,8 @@ export async function main(args) {
1162
1186
  visibility: options.visibility,
1163
1187
  description: options.description,
1164
1188
  force: options.force,
1165
- yes: options.yes
1189
+ yes: options.yes,
1190
+ retry: options.retry
1166
1191
  });
1167
1192
  if (options.openIn) {
1168
1193
  const engine = options.openIn;
@@ -1174,10 +1199,18 @@ export async function main(args) {
1174
1199
  }
1175
1200
  }
1176
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
+ });
1177
1210
  repoCommand
1178
1211
  .command('pull <name>')
1179
1212
  .alias('download')
1180
- .description('Pull a project from your Vigthoria Repo')
1213
+ .description('Pull a project from your Vigthoria Community Repository')
1181
1214
  .option('-o, --output <path>', 'Output directory path')
1182
1215
  .option('-f, --force', 'Overwrite existing directory', false)
1183
1216
  .action(async (name, options) => {
@@ -1190,7 +1223,7 @@ export async function main(args) {
1190
1223
  repoCommand
1191
1224
  .command('list')
1192
1225
  .alias('ls')
1193
- .description('List all your projects in Vigthoria Repo')
1226
+ .description('List all your projects in Vigthoria Community Repository')
1194
1227
  .option('-v, --visibility <type>', 'Filter by visibility (private, restricted, public)')
1195
1228
  .action(async (options) => {
1196
1229
  const repo = new RepoCommand(config, logger);
@@ -1214,14 +1247,14 @@ export async function main(args) {
1214
1247
  repoCommand
1215
1248
  .command('delete <name>')
1216
1249
  .alias('rm')
1217
- .description('Remove a project from your Vigthoria Repo')
1250
+ .description('Remove a project from your Vigthoria Community Repository')
1218
1251
  .action(async (name) => {
1219
1252
  const repo = new RepoCommand(config, logger);
1220
1253
  await repo.delete(name);
1221
1254
  });
1222
1255
  repoCommand
1223
1256
  .command('clone <url>')
1224
- .description('Clone a public project from Vigthoria Repo')
1257
+ .description('Clone a public project from Vigthoria Community')
1225
1258
  .option('-o, --output <path>', 'Output directory path')
1226
1259
  .option('-f, --force', 'Overwrite existing directory', false)
1227
1260
  .action(async (url, options) => {
@@ -1233,7 +1266,7 @@ export async function main(args) {
1233
1266
  });
1234
1267
  repoCommand
1235
1268
  .command('open-in <engine> [projectName]')
1236
- .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')
1237
1270
  .option('--browser', 'Open result URL in default browser', false)
1238
1271
  .option('--shop-id <id>', 'Target shop ID (for shop engine)', 'default')
1239
1272
  .addHelpText('after', `
@@ -1411,6 +1444,12 @@ Examples:
1411
1444
  screenshot: options.screenshot,
1412
1445
  });
1413
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());
1414
1453
  // ==================== LEGION COMMAND ====================
1415
1454
  program
1416
1455
  .command('legion [request]')
@@ -1671,10 +1710,23 @@ Examples:
1671
1710
  console.log(chalk.gray('Continuing with npm registry check...'));
1672
1711
  }
1673
1712
  }
1674
- console.log(chalk.cyan('Checking npm registry...'));
1675
- const npmVersion = await fetchNpmLatestVersion();
1676
- if (!npmVersion) {
1677
- 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
+ }
1678
1730
  }
1679
1731
  const effectiveLatest = maxVersion(manifestEntry?.version, npmVersion);
1680
1732
  if (!effectiveLatest) {
@@ -1682,11 +1734,6 @@ Examples:
1682
1734
  process.exitCode = 1;
1683
1735
  return;
1684
1736
  }
1685
- if (manifestEntry?.version
1686
- && npmVersion
1687
- && compareVersions(npmVersion, manifestEntry.version) > 0) {
1688
- console.log(chalk.yellow(`Release manifest (${manifestEntry.version}) is behind npm (${npmVersion}); npm will be used when needed.`));
1689
- }
1690
1737
  if (!allowDowngrade && compareVersions(effectiveLatest, currentVersion) <= 0) {
1691
1738
  console.log(chalk.green(`You are running the latest version (${currentVersion})`));
1692
1739
  return;
@@ -1802,6 +1849,21 @@ Examples:
1802
1849
  const configCmd = new ConfigCommand(config, logger);
1803
1850
  await configCmd.init();
1804
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
+ });
1805
1867
  const codingCommandDefinitions = [
1806
1868
  { name: 'edit', description: 'Edit code by describing the desired change', instruction: 'Edit the project according to this request' },
1807
1869
  { name: 'generate', description: 'Generate code from a prompt', instruction: 'Generate code for this request' },
@@ -1818,7 +1880,7 @@ Examples:
1818
1880
  .option('-p, --project <path>', 'Project directory', process.cwd())
1819
1881
  .action(async (requestParts = [], options) => {
1820
1882
  const requestText = requestParts.join(' ').trim();
1821
- const chat = new ChatCommand(config, logger);
1883
+ const chat = new ChatCommand(config, logger, program);
1822
1884
  await chat.run({
1823
1885
  model: options.model || 'code',
1824
1886
  project: options.project || process.cwd(),
@@ -1829,8 +1891,10 @@ Examples:
1829
1891
  try {
1830
1892
  // Default to chat if no command
1831
1893
  if (args.length === 2) {
1832
- const chat = new ChatCommand(config, logger);
1833
- 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
+ }
1834
1898
  process.exitCode = 0;
1835
1899
  return;
1836
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
  }